From be0f31766497bb8f70379c25c1993d413ffb143d Mon Sep 17 00:00:00 2001 From: karambo3a Date: Thu, 7 May 2026 00:09:26 +0300 Subject: [PATCH 1/7] add spring-ydb-retry module --- spring-ydb/pom.xml | 40 ++ spring-ydb/spring-ydb-retry/README.md | 84 +++ spring-ydb/spring-ydb-retry/pom.xml | 171 +++++ spring-ydb/spring-ydb-retry/slo/Dockerfile | 22 + spring-ydb/spring-ydb-retry/slo/README.md | 122 ++++ .../spring-ydb-retry/slo/playground/README.md | 96 +++ .../slo/playground/chaos-aggressive/chaos.sh | 118 ++++ .../playground/chaos-aggressive/compose.yaml | 381 +++++++++++ .../slo/playground/chaos/chaos.sh | 52 ++ .../slo/playground/chaos/compose.yaml | 373 ++++++++++ .../provisioning/dashboards/dashboard.yaml | 9 + .../grafana/provisioning/dashboards/slo.json | 638 ++++++++++++++++++ .../provisioning/datasources/datasource.yaml | 8 + .../configs/prometheus/prometheus.yaml | 16 + .../slo/playground/configs/ydb.yaml | 63 ++ spring-ydb/spring-ydb-retry/slo/pom.xml | 92 +++ spring-ydb/spring-ydb-retry/slo/src/README.md | 143 ++++ .../main/java/tech/ydb/slo/OtelConfig.java | 28 + .../java/tech/ydb/slo/SloApplication.java | 13 + .../src/main/java/tech/ydb/slo/SloConfig.java | 71 ++ .../java/tech/ydb/slo/SloResultWriter.java | 210 ++++++ .../src/main/java/tech/ydb/slo/SloRunner.java | 356 ++++++++++ .../main/java/tech/ydb/slo/SloService.java | 60 ++ .../src/main/java/tech/ydb/slo/SloStats.java | 155 +++++ .../src/main/resources/application.properties | 24 + .../java/tech/ydb/retry/BackoffSleeper.java | 6 + .../tech/ydb/retry/YdbDelayCalculator.java | 36 + .../java/tech/ydb/retry/YdbRetryPolicy.java | 10 + .../tech/ydb/retry/YdbRetryPolicyConfig.java | 142 ++++ .../tech/ydb/retry/YdbRetryProperties.java | 83 +++ .../YdbTransactionAutoConfiguration.java | 28 + .../ydb/retry/YdbTransactionInterceptor.java | 151 +++++ .../YdbTransactionInterceptorFactory.java | 72 ++ .../YdbTransactionInterceptorReplacer.java | 83 +++ .../java/tech/ydb/retry/YdbTransactional.java | 71 ++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../ydb/retry/InterceptorTestSupport.java | 196 ++++++ .../TransactionPropagationRetryTest.java | 66 ++ .../retry/TransactionalDefaultRetryTest.java | 222 ++++++ .../ydb/retry/YdbRetryPolicyConfigTest.java | 311 +++++++++ .../tech/ydb/retry/YdbRetryPolicyTest.java | 105 +++ .../YdbTransactionInterceptorFactoryTest.java | 104 +++ ...YdbTransactionInterceptorReplacerTest.java | 168 +++++ .../YdbTransactionManagerResolutionTest.java | 339 ++++++++++ .../YdbTransactionalConfigOverrideTest.java | 333 +++++++++ .../CombinedErrorIntegrationTest.java | 88 +++ .../CommitTransactionRetryTest.java | 63 ++ .../retry/integration/ConcurrentRunner.java | 75 ++ .../ConcurrentWriteIntegrationTest.java | 84 +++ .../DeterministicErrorChannel.java | 162 +++++ .../DeterministicErrorChannelTest.java | 24 + .../DisabledRetryIntegrationTest.java | 52 ++ .../ExecuteQueryRetryIntegrationTest.java | 88 +++ .../integration/HappyPathIntegrationTest.java | 99 +++ .../IdempotentRetryIntegrationTest.java | 99 +++ .../IntegrationEnvironmentTest.java | 22 + .../integration/MaxRetriesExhaustedTest.java | 59 ++ .../NonRetryableCommitIntegrationTest.java | 59 ++ .../ydb/retry/integration/YdbDockerTest.java | 28 + .../integration/app/SimpleUserRepository.java | 13 + .../tech/ydb/retry/integration/app/User.java | 88 +++ .../integration/app/UserApplication.java | 13 + .../retry/integration/app/UserService.java | 51 ++ .../resources/application-disabled.properties | 7 + .../resources/application-enabled.properties | 13 + .../test/resources/application-ydb.properties | 2 + .../db/migration/V1__create_table.sql | 9 + 67 files changed, 7070 insertions(+) create mode 100644 spring-ydb/pom.xml create mode 100644 spring-ydb/spring-ydb-retry/README.md create mode 100644 spring-ydb/spring-ydb-retry/pom.xml create mode 100644 spring-ydb/spring-ydb-retry/slo/Dockerfile create mode 100644 spring-ydb/spring-ydb-retry/slo/README.md create mode 100644 spring-ydb/spring-ydb-retry/slo/playground/README.md create mode 100755 spring-ydb/spring-ydb-retry/slo/playground/chaos-aggressive/chaos.sh create mode 100644 spring-ydb/spring-ydb-retry/slo/playground/chaos-aggressive/compose.yaml create mode 100755 spring-ydb/spring-ydb-retry/slo/playground/chaos/chaos.sh create mode 100644 spring-ydb/spring-ydb-retry/slo/playground/chaos/compose.yaml create mode 100644 spring-ydb/spring-ydb-retry/slo/playground/configs/grafana/provisioning/dashboards/dashboard.yaml create mode 100644 spring-ydb/spring-ydb-retry/slo/playground/configs/grafana/provisioning/dashboards/slo.json create mode 100644 spring-ydb/spring-ydb-retry/slo/playground/configs/grafana/provisioning/datasources/datasource.yaml create mode 100644 spring-ydb/spring-ydb-retry/slo/playground/configs/prometheus/prometheus.yaml create mode 100644 spring-ydb/spring-ydb-retry/slo/playground/configs/ydb.yaml create mode 100644 spring-ydb/spring-ydb-retry/slo/pom.xml create mode 100644 spring-ydb/spring-ydb-retry/slo/src/README.md create mode 100644 spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/OtelConfig.java create mode 100644 spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloApplication.java create mode 100644 spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloConfig.java create mode 100644 spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloResultWriter.java create mode 100644 spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloRunner.java create mode 100644 spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloService.java create mode 100644 spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloStats.java create mode 100644 spring-ydb/spring-ydb-retry/slo/src/main/resources/application.properties create mode 100644 spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/BackoffSleeper.java create mode 100644 spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbDelayCalculator.java create mode 100644 spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbRetryPolicy.java create mode 100644 spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbRetryPolicyConfig.java create mode 100644 spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbRetryProperties.java create mode 100644 spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionAutoConfiguration.java create mode 100644 spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptor.java create mode 100644 spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorFactory.java create mode 100644 spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorReplacer.java create mode 100644 spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactional.java create mode 100644 spring-ydb/spring-ydb-retry/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/InterceptorTestSupport.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/TransactionPropagationRetryTest.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/TransactionalDefaultRetryTest.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbRetryPolicyConfigTest.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbRetryPolicyTest.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorFactoryTest.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorReplacerTest.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionManagerResolutionTest.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionalConfigOverrideTest.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/CombinedErrorIntegrationTest.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/CommitTransactionRetryTest.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ConcurrentRunner.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ConcurrentWriteIntegrationTest.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DeterministicErrorChannel.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DeterministicErrorChannelTest.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DisabledRetryIntegrationTest.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ExecuteQueryRetryIntegrationTest.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/HappyPathIntegrationTest.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/IdempotentRetryIntegrationTest.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/IntegrationEnvironmentTest.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/MaxRetriesExhaustedTest.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/NonRetryableCommitIntegrationTest.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/YdbDockerTest.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/app/SimpleUserRepository.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/app/User.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/app/UserApplication.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/app/UserService.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/resources/application-disabled.properties create mode 100644 spring-ydb/spring-ydb-retry/src/test/resources/application-enabled.properties create mode 100644 spring-ydb/spring-ydb-retry/src/test/resources/application-ydb.properties create mode 100644 spring-ydb/spring-ydb-retry/src/test/resources/db/migration/V1__create_table.sql diff --git a/spring-ydb/pom.xml b/spring-ydb/pom.xml new file mode 100644 index 00000000..87a2e508 --- /dev/null +++ b/spring-ydb/pom.xml @@ -0,0 +1,40 @@ + + + + 4.0.0 + + tech.ydb + spring-ydb + 1.0.0-SNAPSHOT + pom + Spring YDB + Spring integration modules for YDB + + + spring-ydb-retry + + + + 21 + 21 + 21 + UTF-8 + 6.2.0 + 3.4.0 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + \ No newline at end of file diff --git a/spring-ydb/spring-ydb-retry/README.md b/spring-ydb/spring-ydb-retry/README.md new file mode 100644 index 00000000..13e101e5 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/README.md @@ -0,0 +1,84 @@ +# Spring YDB Retry + +## Overview + +This project is a Spring Boot auto-configuration module that provides automatic retry +for transactional operations with [YDB](https://ydb.tech). + +### Features + +- Automatic retry of failed `@Transactional` methods on YDB retryable status codes +- `@YdbTransactional` annotation with per-method retry settings (maxRetries, backoff, idempotency) +- Dual backoff strategy (fast/slow) with jitter tailored to YDB error semantics +- Idempotent mode for extended retry coverage on non-deterministic status codes +- Fully configurable via `application.properties` + +## Getting Started + +### Requirements + +- Java 21 or above +- Spring Boot 3.4+ / Spring Framework 6.2+ +- [YDB JDBC Driver](https://github.com/ydb-platform/ydb-jdbc-driver) +- Access to a YDB Database instance + +### Installation + +For Maven, add the following dependency to your pom.xml: + +```xml + + tech.ydb + spring-ydb-retry + + ${spring-ydb-retry.version} + +``` + +For Gradle, add the following to your build.gradle (or build.gradle.kts): + +```groovy +dependencies { + implementation 'tech.ydb:spring-ydb-retry:$version' // Set actual version +} +``` + +## Usage + +The module is auto-configured via Spring Boot. Once the dependency is on the classpath, +all `@Transactional` (and `@YdbTransactional`) methods are intercepted with retry logic. + +### Annotation + +Use `@YdbTransactional` as a drop-in replacement for `@Transactional` with additional +retry parameters: + +```java +@YdbTransactional(maxRetries = 5, idempotent = 1) +public void save(User user) { + // retried up to 5 times on YDB retryable errors +} +``` + +### Configuration + +Configure retry behavior in `application.properties`: + +```properties +# Enable/disable retry (default: true) +ydb.transaction.retry.enabled=true + +# Maximum retry attempts (default: 10) +ydb.transaction.retry.max-retries=10 + +# Backoff settings for slow errors +ydb.transaction.retry.slow-backoff-base-ms=50 +ydb.transaction.retry.slow-cap-backoff-ms=5000 + +# Backoff settings for fast errors +ydb.transaction.retry.fast-backoff-base-ms=5 +ydb.transaction.retry.fast-cap-backoff-ms=500 + +# Enable idempotent retry for non-deterministic errors (default: false) +ydb.transaction.retry.idempotent=false +``` diff --git a/spring-ydb/spring-ydb-retry/pom.xml b/spring-ydb/spring-ydb-retry/pom.xml new file mode 100644 index 00000000..f871f08f --- /dev/null +++ b/spring-ydb/spring-ydb-retry/pom.xml @@ -0,0 +1,171 @@ + + + + 4.0.0 + + + tech.ydb + spring-ydb + 1.0.0-SNAPSHOT + + + spring-ydb-retry + jar + + Spring YDB Retry + Spring retry module for YDB + + + + tech.ydb.dialects + spring-data-jdbc-ydb + 1.1.0 + + + org.springframework.boot + spring-boot-autoconfigure + ${spring-boot.version} + provided + true + + + org.springframework.boot + spring-boot + ${spring-boot.version} + provided + true + + + org.springframework + spring-tx + ${spring.version} + provided + + + org.springframework + spring-context + ${spring.version} + provided + + + org.springframework + spring-aop + ${spring.version} + provided + + + org.springframework + spring-core + ${spring.version} + provided + + + + tech.ydb.jdbc + ydb-jdbc-driver + 2.3.22 + provided + + + org.junit.jupiter + junit-jupiter + 5.11.4 + test + + + org.mockito + mockito-core + 5.15.2 + test + + + org.springframework + spring-test + ${spring.version} + test + + + org.springframework.data + spring-data-jdbc + test + + + org.flywaydb + flyway-core + test + + + tech.ydb.dialects + flyway-ydb-dialect + 1.0.0 + test + + + org.springframework.boot + spring-boot-starter-jdbc + test + + + org.springframework.boot + spring-boot-starter-test + test + + + tech.ydb.test + ydb-junit5-support + 2.3.22 + test + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + tech.ydb.spring.retry + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + UTF-8 + + + + org.apache.maven.plugins + maven-source-plugin + 3.2.1 + + + attach-sources + verify + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + true + + + + + + \ No newline at end of file diff --git a/spring-ydb/spring-ydb-retry/slo/Dockerfile b/spring-ydb/spring-ydb-retry/slo/Dockerfile new file mode 100644 index 00000000..b1e79e2e --- /dev/null +++ b/spring-ydb/spring-ydb-retry/slo/Dockerfile @@ -0,0 +1,22 @@ +FROM maven:3.9-eclipse-temurin-21 AS build + +WORKDIR /build + +COPY pom.xml ./pom.xml +RUN mvn install -N -B + +COPY spring-ydb-retry/pom.xml ./spring-ydb-retry/pom.xml +COPY spring-ydb-retry/src ./spring-ydb-retry/src +RUN mvn install -DskipTests -B -pl spring-ydb-retry || mvn install -DskipTests -B -pl spring-ydb-retry + +COPY spring-ydb-retry/slo/pom.xml ./spring-ydb-retry/slo/pom.xml +COPY spring-ydb-retry/slo/src ./spring-ydb-retry/slo/src +RUN cd spring-ydb-retry/slo && mvn package -DskipTests -B || mvn package -DskipTests -B + +FROM eclipse-temurin:21-jre + +WORKDIR /app +COPY --from=build /build/spring-ydb-retry/slo/target/ydb-slo-workload-1.0.0-SNAPSHOT-exec.jar app.jar + +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/spring-ydb/spring-ydb-retry/slo/README.md b/spring-ydb/spring-ydb-retry/slo/README.md new file mode 100644 index 00000000..28e42248 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/slo/README.md @@ -0,0 +1,122 @@ +# SLO Testing for YDB Spring Retry + +SLO (Service Level Objectives) testing validates that the **spring-ydb-retry** library reduces visible application errors during YDB cluster node failures — restarts, shutdowns, network issues, and kill signals. + +## How It Works + +Two identical Spring Boot applications run the same workload (read/write) against the same YDB cluster: + +| Instance | Port | Retry | Description | +|---|---|---|---| +| `app-with-retry` | 8081 | **Enabled** (max 10 retries, idempotent=true) | Uses the same workload with global retry enabled | +| `app-no-retry` | 8082 | **Disabled** | Uses the same workload with `YDB_TRANSACTION_RETRY_ENABLED=false` | + +A chaos script periodically stops, restarts, and kills random YDB nodes. The Grafana dashboard shows an error rate comparison, clearly demonstrating that retry significantly reduces visible application errors. + +## Test Scenarios + +Two chaos levels are available: + +| Scenario | Directory | Description | +|---|---|---| +| **chaos** | `playground/chaos/` | Baseline: stop/start, restart, SIGKILL of individual nodes | +| **chaos-aggressive** | `playground/chaos-aggressive/` | Aggressive: pause/unpause, multi-node kill, rapid kill/start, triple kill + resource constraints | + +See [`playground/README.md`](playground/README.md) for details. + +## Quick Start + +### 1. Start (baseline chaos) + +```bash +cd slo/playground/chaos +docker compose up --build -d +``` + +Wait ~60 seconds for YDB to initialize and apps to seed data. + +### 2. Start (aggressive chaos) + +```bash +cd slo/playground/chaos-aggressive +docker compose up --build -d +``` + +### 3. Open Grafana + +Navigate to **http://localhost:3000** (login: `admin` / `admin`). + +The **"YDB Spring Retry SLO - Retry vs No-Retry Comparison"** dashboard is pre-loaded and auto-refreshes every 5 seconds. + +### 4. Stop + +```bash +docker compose down +``` + +To also remove data volumes: + +```bash +docker compose down -v +``` + +## Services + +| Service | URL | Description | +|---|---|---| +| Grafana | http://localhost:3000 | Metrics dashboard (admin/admin) | +| Prometheus | http://localhost:9090 | Metrics storage | +| YDB Monitoring | http://localhost:8765 | YDB cluster UI | +| YDB gRPC | grpc://localhost:2136 | YDB endpoint | +| App with retry metrics | internal `http://app-with-retry:9464/metrics` | Prometheus scrape target | +| App without retry metrics | internal `http://app-no-retry:9464/metrics` | Prometheus scrape target | + +The app containers do not publish their internal Spring Boot or metrics ports to the host. Prometheus scrapes them over the Docker network at `:9464/metrics`. + +## Metrics + +The SLO application exports Prometheus metrics via OpenTelemetry SDK: + +| Metric | Type | Labels | Description | +|---|---|---|---| +| `slo_operations_total` | Counter | ref, operation_type, status, error_type | Total operations | +| `slo_operation_duration_seconds` | Histogram | ref, operation_type, status, error_type | Operation latency | + +### Labels + +| Label | Values | Description | +|---|---|---| +| `ref` | `with-retry`, `no-retry` | Instance identifier | +| `operation_type` | `read`, `write` | Operation type | +| `status` | `success`, `failure` | Operation result | +| `error_type` | `none`, `UNAVAILABLE`, `TRANSPORT_UNAVAILABLE`, `OVERLOADED`, `BAD_SESSION`, ... | YDB status code or exception class name | + +## Configuration + +Environment variables for the app containers: + +| Variable | Default | Description | +|---|---|---| +| `SERVER_PORT` | 8080 | HTTP port | +| `SPRING_DATASOURCE_URL` | - | YDB JDBC URL | +| `YDB_TRANSACTION_RETRY_ENABLED` | true | Enable/disable retry | +| `YDB_TRANSACTION_RETRY_MAX_RETRIES` | 10 | Max retry attempts | +| `YDB_TRANSACTION_RETRY_IDEMPOTENT` | true | Treat operations as idempotent | +| `SLO_RUN_ID` | auto | Shared run identifier used for result folder name | +| `SLO_RESULTS_DIR` | `/app/results` in Docker | Root directory for saved run results | +| `REF` | unknown | Label for metrics (with-retry / no-retry) | +| `SLO_READ_RPS` | 100 | Target read RPS | +| `SLO_WRITE_RPS` | 100 | Target write RPS | +| `SLO_INITIAL_DATA` | 1000 | Initial rows to seed | +| `SLO_TIME` | 600 | Workload duration in seconds | + +## Saved Results + +```text +results/ + / + retry + no-retry +``` + +The `retry` file contains the final summary for `app-with-retry`, and `no-retry` contains the final summary for `app-no-retry`. diff --git a/spring-ydb/spring-ydb-retry/slo/playground/README.md b/spring-ydb/spring-ydb-retry/slo/playground/README.md new file mode 100644 index 00000000..802648fc --- /dev/null +++ b/spring-ydb/spring-ydb-retry/slo/playground/README.md @@ -0,0 +1,96 @@ +# Playground + +Docker Compose environments for running SLO tests with chaos injection. Each scenario deploys a full YDB cluster, two workload applications (with and without retry), Prometheus, Grafana, and a chaos container. + +## Shared Infrastructure + +All scenarios use the same architecture: + +| Component | Count | Description | +|---|---|---| +| YDB static node | 1 | Storage node + discovery (`static-0`) | +| YDB database nodes | 5 | Tenant nodes (`database-1` .. `database-5`) | +| SLO app with retry | 1 | Port 8081, retry enabled | +| SLO app without retry | 1 | Port 8082, retry disabled | +| Prometheus | 1 | Scrapes metrics every 5s | +| Grafana | 1 | Visualization on port 3000 | +| Chaos container | 1 | Docker container with docker.sock access | + +All services run on a single Docker network `slo-network`. The YDB cluster uses erasure `none` (no storage-level replication), which amplifies the impact of failures. + +--- + +## Scenario 1: `chaos/` — Baseline Chaos + +A mild scenario modeling typical operational failures: graceful shutdown, restart, and crash of a single node at a time. + +### Start + +```bash +cd slo/playground/chaos +docker compose up --build -d +``` + +### Chaos Phases (`chaos.sh`) + +The chaos script starts 60 seconds after launch (once YDB and apps are ready). + +| Phase | Iterations | Action | Pause | Generated Errors | +|---|---|---|---|---| +| Stop/Start | 5 | `docker stop` → `docker start` a random node | 60s | `UNAVAILABLE`, `TRANSPORT_UNAVAILABLE` | +| Restart | 3 | `docker restart -t 0` a random node (instant) | 60s | `TRANSPORT_UNAVAILABLE` | +| Final Kill | 1 | `docker kill -s SIGKILL` a random node | — | `UNAVAILABLE`, `BAD_SESSION` | + +**Total chaos duration:** ~8 minutes after the 60s delay. + +--- + +## Scenario 2: `chaos-aggressive/` — Aggressive Chaos + +An intensive scenario with multi-node failures, pause/unpause, and rapid kill/start cycles. YDB nodes run with constrained resources (768 MB RAM, 1 CPU), amplifying the effect. + +### Start + +```bash +cd slo/playground/chaos-aggressive +docker compose up --build -d +``` + +### Chaos Phases (`chaos.sh`) + +| Phase | Iterations | Action | Pause | +|---|---|---|---| +| 1. Pause/Unpause | 4 | `docker pause` 20s → `docker unpause` one node | 15s | +| 2. Multi-node Kill | 3 | `SIGKILL` **two** nodes simultaneously → `docker start` both | 25s | +| 3. Instant Restart | 3 | `docker restart -t 0` one node | 20s | +| 4. Dual Pause | 1 | `docker pause` **two** nodes for 30s → unpause | 15s | +| 5. Rapid Kill/Start | 5 | `SIGKILL` → `docker start` with no gap | 8s | +| 6. Final Triple Kill | 1 | `SIGKILL` **three** nodes simultaneously | — | + +**Total chaos duration:** ~7 minutes after the 60s delay. + +--- + +## Configuration Files + +### `configs/ydb.yaml` + +YDB cluster configuration with erasure `none`, a single storage pool (SSD), and 5 database nodes connected to the tenant `/Root/testdb`. + +### `configs/prometheus/prometheus.yaml` + +Scrape configuration: both apps are scraped every 5 seconds at `:9464/metrics`. + +### `configs/grafana/provisioning/` + +- **datasource.yaml** — Prometheus datasource +- **dashboard.yaml** — Auto-loads JSON dashboards from the directory +- **slo.json** — Pre-built dashboard + +## Cleanup + +```bash +docker compose down -v +``` + +Removes containers, networks, and volumes (Prometheus data, Grafana DB). diff --git a/spring-ydb/spring-ydb-retry/slo/playground/chaos-aggressive/chaos.sh b/spring-ydb/spring-ydb-retry/slo/playground/chaos-aggressive/chaos.sh new file mode 100755 index 00000000..20ba8376 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/slo/playground/chaos-aggressive/chaos.sh @@ -0,0 +1,118 @@ +#!/bin/sh -e + +get_random_container() { + # Get a list of all containers starting with ydb-database-* + containers=$(docker ps --format '{{.Names}}' | grep '^ydb-database-') + + # Convert the list to a newline-separated string + containers=$(echo "$containers" | tr ' ' '\n') + + # Count the number of containers + containersCount=$(echo "$containers" | wc -l) + + # Generate a random number between 0 and containersCount - 1 + randomIndex=$(shuf -i 0-$(($containersCount - 1)) -n 1) + + # Get the container name at the random index + nodeForChaos=$(echo "$containers" | sed -n "$(($randomIndex + 1))p") +} + +get_two_random_containers() { + containers=$(docker ps --format '{{.Names}}' | grep '^ydb-database-') + containers=$(echo "$containers" | tr ' ' '\n') + containersCount=$(echo "$containers" | wc -l) + if [ "$containersCount" -lt 2 ]; then + get_random_container + nodeForChaos2="" + return + fi + randomIndex1=$(shuf -i 0-$(($containersCount - 1)) -n 1) + randomIndex2=$(shuf -i 0-$(($containersCount - 2)) -n 1) + if [ "$randomIndex2" -ge "$randomIndex1" ]; then + randomIndex2=$(($randomIndex2 + 1)) + fi + nodeForChaos=$(echo "$containers" | sed -n "$(($randomIndex1 + 1))p") + nodeForChaos2=$(echo "$containers" | sed -n "$(($randomIndex2 + 1))p") +} + +sleep 60 + +echo "Start AGGRESSIVE CHAOS on YDB cluster!" + +# Phase 1: Pause/unpause +echo "=== Phase 1: docker pause/unpause ===" +for i in $(seq 1 4) +do + get_random_container + echo "[$(date)]: PAUSE ${nodeForChaos} (iteration $i) — in-flight ops will hang" + docker pause ${nodeForChaos} + sleep 20 + echo "[$(date)]: UNPAUSE ${nodeForChaos}" + docker unpause ${nodeForChaos} + sleep 15 +done + +# Phase 2: Multi-node simultaneous kill +echo "=== Phase 2: multi-node kill ===" +for i in $(seq 1 3) +do + get_two_random_containers + echo "[$(date)]: KILL ${nodeForChaos} and ${nodeForChaos2} simultaneously (iteration $i)" + docker kill -s SIGKILL ${nodeForChaos} & + docker kill -s SIGKILL ${nodeForChaos2} & + wait + echo "[$(date)]: Starting both nodes back" + docker start ${nodeForChaos} & + docker start ${nodeForChaos2} & + wait + sleep 25 +done + +# Phase 3: Single-node instant restart +echo "=== Phase 3: instant restart ===" +for i in $(seq 1 3) +do + get_random_container + echo "[$(date)]: INSTANT RESTART ${nodeForChaos} (iteration $i)" + docker restart ${nodeForChaos} -t 0 + sleep 20 +done + +# Phase 4: Pause 2 nodes simultaneously +echo "=== Phase 4: dual pause 30s ===" +get_two_random_containers +echo "[$(date)]: PAUSE ${nodeForChaos} and ${nodeForChaos2} for 30s" +docker pause ${nodeForChaos} & +docker pause ${nodeForChaos2} & +wait +sleep 30 +echo "[$(date)]: UNPAUSE both" +docker unpause ${nodeForChaos} & +docker unpause ${nodeForChaos2} & +wait +sleep 15 + +# Phase 5: Rapid kill/start cycle (session pool thrashing) +echo "=== Phase 5: rapid kill/start ===" +for i in $(seq 1 5) +do + get_random_container + echo "[$(date)]: RAPID kill+start ${nodeForChaos} (iteration $i)" + docker kill -s SIGKILL ${nodeForChaos} + docker start ${nodeForChaos} + sleep 8 +done + +# Phase 6: Final triple kill +echo "=== Phase 6: FINAL triple SIGKILL ===" +containers=$(docker ps --format '{{.Names}}' | grep '^ydb-database-' | shuf) +c1=$(echo "$containers" | sed -n '1p') +c2=$(echo "$containers" | sed -n '2p') +c3=$(echo "$containers" | sed -n '3p') +echo "[$(date)]: SIGKILL ${c1}, ${c2}, ${c3} simultaneously" +docker kill -s SIGKILL ${c1} & +docker kill -s SIGKILL ${c2} & +docker kill -s SIGKILL ${c3} & +wait + +echo "[$(date)]: Chaos complete." diff --git a/spring-ydb/spring-ydb-retry/slo/playground/chaos-aggressive/compose.yaml b/spring-ydb/spring-ydb-retry/slo/playground/chaos-aggressive/compose.yaml new file mode 100644 index 00000000..36d394cf --- /dev/null +++ b/spring-ydb/spring-ydb-retry/slo/playground/chaos-aggressive/compose.yaml @@ -0,0 +1,381 @@ +networks: + slo-network: + driver: bridge + +x-ydb-node: &ydb-node + image: cr.yandex/crptqonuodf51kdj7a7d/ydb:24.4.4.12 + restart: always + platform: linux/amd64 + privileged: true + networks: + - slo-network + volumes: + - ../configs/ydb.yaml:/opt/ydb/cfg/config.yaml + deploy: + resources: + limits: + cpus: '1.0' + memory: 768M + reservations: + cpus: '0.5' + memory: 512M + +name: ydb-slo + +services: + static-0: + <<: *ydb-node + container_name: ydb-static-0 + hostname: static-0 + command: + - /opt/ydb/bin/ydbd + - server + - --grpc-port + - "2135" + - --mon-port + - "8765" + - --ic-port + - "19001" + - --yaml-config + - /opt/ydb/cfg/config.yaml + - --node + - static + - --label + - deployment=docker + ports: + - "2135:2135" + - "8765:8765" + healthcheck: + test: bash -c "exec 6<> /dev/tcp/localhost/2135" + interval: 10s + timeout: 1s + retries: 3 + start_period: 30s + + static-init: + <<: *ydb-node + restart: on-failure + container_name: ydb-static-init + command: + - /opt/ydb/bin/ydbd + - -s + - grpc://static-0:2135 + - admin + - blobstorage + - config + - init + - --yaml-file + - /opt/ydb/cfg/config.yaml + depends_on: + static-0: + condition: service_healthy + + tenant-init: + <<: *ydb-node + restart: on-failure + container_name: ydb-tenant-init + command: + - /opt/ydb/bin/ydbd + - -s + - grpc://static-0:2135 + - admin + - database + - /Root/testdb + - create + - ssd:1 + depends_on: + static-init: + condition: service_completed_successfully + + database-1: + <<: *ydb-node + container_name: ydb-database-1 + hostname: database-1 + command: + - /opt/ydb/bin/ydbd + - server + - --grpc-port + - "2136" + - --mon-port + - "8766" + - --ic-port + - "19002" + - --yaml-config + - /opt/ydb/cfg/config.yaml + - --tenant + - /Root/testdb + - --node-broker + - grpc://static-0:2135 + - --label + - deployment=docker + ports: + - "2136:2136" + - "8766:8766" + healthcheck: + test: bash -c "exec 6<> /dev/tcp/localhost/2136" + interval: 10s + timeout: 1s + retries: 3 + start_period: 30s + depends_on: + static-0: + condition: service_healthy + static-init: + condition: service_completed_successfully + tenant-init: + condition: service_completed_successfully + + database-2: + <<: *ydb-node + container_name: ydb-database-2 + hostname: database-2 + command: + - /opt/ydb/bin/ydbd + - server + - --grpc-port + - "2137" + - --mon-port + - "8767" + - --ic-port + - "19003" + - --yaml-config + - /opt/ydb/cfg/config.yaml + - --tenant + - /Root/testdb + - --node-broker + - grpc://static-0:2135 + - --label + - deployment=docker + ports: + - "2137:2137" + - "8767:8767" + healthcheck: + test: bash -c "exec 6<> /dev/tcp/localhost/2137" + interval: 10s + timeout: 1s + retries: 3 + start_period: 30s + depends_on: + static-0: + condition: service_healthy + static-init: + condition: service_completed_successfully + tenant-init: + condition: service_completed_successfully + + database-3: + <<: *ydb-node + container_name: ydb-database-3 + hostname: database-3 + command: + - /opt/ydb/bin/ydbd + - server + - --grpc-port + - "2138" + - --mon-port + - "8768" + - --ic-port + - "19004" + - --yaml-config + - /opt/ydb/cfg/config.yaml + - --tenant + - /Root/testdb + - --node-broker + - grpc://static-0:2135 + - --label + - deployment=docker + ports: + - "2138:2138" + - "8768:8768" + healthcheck: + test: bash -c "exec 6<> /dev/tcp/localhost/2138" + interval: 10s + timeout: 1s + retries: 3 + start_period: 30s + depends_on: + static-0: + condition: service_healthy + static-init: + condition: service_completed_successfully + tenant-init: + condition: service_completed_successfully + + database-4: + <<: *ydb-node + container_name: ydb-database-4 + hostname: database-4 + command: + - /opt/ydb/bin/ydbd + - server + - --grpc-port + - "2139" + - --mon-port + - "8769" + - --ic-port + - "19005" + - --yaml-config + - /opt/ydb/cfg/config.yaml + - --tenant + - /Root/testdb + - --node-broker + - grpc://static-0:2135 + - --label + - deployment=docker + ports: + - "2139:2139" + - "8769:8769" + healthcheck: + test: bash -c "exec 6<> /dev/tcp/localhost/2139" + interval: 10s + timeout: 1s + retries: 3 + start_period: 30s + depends_on: + static-0: + condition: service_healthy + static-init: + condition: service_completed_successfully + tenant-init: + condition: service_completed_successfully + + database-5: + <<: *ydb-node + container_name: ydb-database-5 + hostname: database-5 + command: + - /opt/ydb/bin/ydbd + - server + - --grpc-port + - "2140" + - --mon-port + - "8770" + - --ic-port + - "19006" + - --yaml-config + - /opt/ydb/cfg/config.yaml + - --tenant + - /Root/testdb + - --node-broker + - grpc://static-0:2135 + - --label + - deployment=docker + ports: + - "2140:2140" + - "8770:8770" + healthcheck: + test: bash -c "exec 6<> /dev/tcp/localhost/2140" + interval: 10s + timeout: 1s + retries: 3 + start_period: 30s + depends_on: + static-0: + condition: service_healthy + static-init: + condition: service_completed_successfully + tenant-init: + condition: service_completed_successfully + + app-with-retry: + build: + context: ../../../.. + dockerfile: spring-ydb-retry/slo/Dockerfile + container_name: ydb-app-with-retry + platform: linux/amd64 + networks: + - slo-network + environment: + SERVER_PORT: "8081" + SPRING_DATASOURCE_URL: jdbc:ydb:grpc://static-0:2135/Root/testdb + SPRING_DATASOURCE_DRIVER_CLASS_NAME: tech.ydb.jdbc.YdbDriver + YDB_TRANSACTION_RETRY_ENABLED: "true" + YDB_TRANSACTION_RETRY_MAX_RETRIES: "10" + YDB_TRANSACTION_RETRY_IDEMPOTENT: "true" + REF: with-retry + SLO_RUN_ID: ${SLO_RUN_ID:-} + SLO_RESULTS_DIR: /app/results + SLO_READ_RPS: "100" + SLO_WRITE_RPS: "100" + SLO_INITIAL_DATA: "1000" + SLO_TIME: "600" + volumes: + - ../results:/app/results + depends_on: + static-0: + condition: service_healthy + + app-no-retry: + build: + context: ../../../.. + dockerfile: spring-ydb-retry/slo/Dockerfile + container_name: ydb-app-no-retry + platform: linux/amd64 + networks: + - slo-network + environment: + SERVER_PORT: "8082" + SPRING_DATASOURCE_URL: jdbc:ydb:grpc://static-0:2135/Root/testdb + SPRING_DATASOURCE_DRIVER_CLASS_NAME: tech.ydb.jdbc.YdbDriver + YDB_TRANSACTION_RETRY_ENABLED: "false" + REF: no-retry + SLO_RUN_ID: ${SLO_RUN_ID:-} + SLO_RESULTS_DIR: /app/results + SLO_READ_RPS: "100" + SLO_WRITE_RPS: "100" + SLO_INITIAL_DATA: "1000" + SLO_TIME: "600" + volumes: + - ../results:/app/results + depends_on: + static-0: + condition: service_healthy + + chaos: + image: docker:latest + restart: on-failure + container_name: ydb-chaos + platform: linux/amd64 + networks: + - slo-network + entrypoint: ["/bin/sh", "-c", "chmod +x /opt/ydb/chaos.sh && /opt/ydb/chaos.sh"] + volumes: + - ./chaos.sh:/opt/ydb/chaos.sh + - ../configs/ydb.yaml:/opt/ydb/cfg/config.yaml + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + static-0: + condition: service_healthy + + prometheus: + image: prom/prometheus:v3.3.1 + container_name: prometheus + networks: + - slo-network + volumes: + - ../configs/prometheus:/etc/prometheus + - ../data/prometheus:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yaml' + - '--storage.tsdb.path=/prometheus' + - '--storage.tsdb.retention.time=200h' + - '--web.enable-lifecycle' + - '--web.enable-otlp-receiver' + ports: + - "9090:9090" + + grafana: + image: grafana/grafana:9.5.3 + container_name: grafana + networks: + - slo-network + volumes: + - ../configs/grafana/provisioning:/etc/grafana/provisioning + - ../data/grafana:/var/lib/grafana + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + ports: + - "3000:3000" diff --git a/spring-ydb/spring-ydb-retry/slo/playground/chaos/chaos.sh b/spring-ydb/spring-ydb-retry/slo/playground/chaos/chaos.sh new file mode 100755 index 00000000..521bed8b --- /dev/null +++ b/spring-ydb/spring-ydb-retry/slo/playground/chaos/chaos.sh @@ -0,0 +1,52 @@ +#!/bin/sh -e + +get_random_container() { + # Get a list of all containers starting with ydb-database-* + containers=$(docker ps --format '{{.Names}}' | grep '^ydb-database-') + + # Convert the list to a newline-separated string + containers=$(echo "$containers" | tr ' ' '\n') + + # Count the number of containers + containersCount=$(echo "$containers" | wc -l) + + # Generate a random number between 0 and containersCount - 1 + randomIndex=$(shuf -i 0-$(($containersCount - 1)) -n 1) + + # Get the container name at the random index + nodeForChaos=$(echo "$containers" | sed -n "$(($randomIndex + 1))p") +} + + +sleep 60 + +echo "Start CHAOS YDB cluster!" + +for i in $(seq 1 5) +do + echo "[$(date)]: docker stop/start iteration $i" + + get_random_container + + sh -c "docker stop ${nodeForChaos} -t 10" + sh -c "docker start ${nodeForChaos}" + + sleep 60 +done + +for i in $(seq 1 3) +do + echo "[$(date)]: docker restart iteration $i" + + get_random_container + + sh -c "docker restart ${nodeForChaos} -t 0" + + sleep 60 +done + +get_random_container + +echo "[$(date)]: docker kill -s SIGKILL ${nodeForChaos}" + +sh -c "docker kill -s SIGKILL ${nodeForChaos}" diff --git a/spring-ydb/spring-ydb-retry/slo/playground/chaos/compose.yaml b/spring-ydb/spring-ydb-retry/slo/playground/chaos/compose.yaml new file mode 100644 index 00000000..d8db77e3 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/slo/playground/chaos/compose.yaml @@ -0,0 +1,373 @@ +networks: + slo-network: + driver: bridge + +x-ydb-node: &ydb-node + image: cr.yandex/crptqonuodf51kdj7a7d/ydb:24.4.4.12 + restart: always + platform: linux/amd64 + privileged: true + networks: + - slo-network + volumes: + - ../configs/ydb.yaml:/opt/ydb/cfg/config.yaml + +name: ydb-slo + +services: + static-0: + <<: *ydb-node + container_name: ydb-static-0 + hostname: static-0 + command: + - /opt/ydb/bin/ydbd + - server + - --grpc-port + - "2135" + - --mon-port + - "8765" + - --ic-port + - "19001" + - --yaml-config + - /opt/ydb/cfg/config.yaml + - --node + - static + - --label + - deployment=docker + ports: + - "2135:2135" + - "8765:8765" + healthcheck: + test: bash -c "exec 6<> /dev/tcp/localhost/2135" + interval: 10s + timeout: 1s + retries: 3 + start_period: 30s + + static-init: + <<: *ydb-node + restart: on-failure + container_name: ydb-static-init + command: + - /opt/ydb/bin/ydbd + - -s + - grpc://static-0:2135 + - admin + - blobstorage + - config + - init + - --yaml-file + - /opt/ydb/cfg/config.yaml + depends_on: + static-0: + condition: service_healthy + + tenant-init: + <<: *ydb-node + restart: on-failure + container_name: ydb-tenant-init + command: + - /opt/ydb/bin/ydbd + - -s + - grpc://static-0:2135 + - admin + - database + - /Root/testdb + - create + - ssd:1 + depends_on: + static-init: + condition: service_completed_successfully + + database-1: + <<: *ydb-node + container_name: ydb-database-1 + hostname: database-1 + command: + - /opt/ydb/bin/ydbd + - server + - --grpc-port + - "2136" + - --mon-port + - "8766" + - --ic-port + - "19002" + - --yaml-config + - /opt/ydb/cfg/config.yaml + - --tenant + - /Root/testdb + - --node-broker + - grpc://static-0:2135 + - --label + - deployment=docker + ports: + - "2136:2136" + - "8766:8766" + healthcheck: + test: bash -c "exec 6<> /dev/tcp/localhost/2136" + interval: 10s + timeout: 1s + retries: 3 + start_period: 30s + depends_on: + static-0: + condition: service_healthy + static-init: + condition: service_completed_successfully + tenant-init: + condition: service_completed_successfully + + database-2: + <<: *ydb-node + container_name: ydb-database-2 + hostname: database-2 + command: + - /opt/ydb/bin/ydbd + - server + - --grpc-port + - "2137" + - --mon-port + - "8767" + - --ic-port + - "19003" + - --yaml-config + - /opt/ydb/cfg/config.yaml + - --tenant + - /Root/testdb + - --node-broker + - grpc://static-0:2135 + - --label + - deployment=docker + ports: + - "2137:2137" + - "8767:8767" + healthcheck: + test: bash -c "exec 6<> /dev/tcp/localhost/2137" + interval: 10s + timeout: 1s + retries: 3 + start_period: 30s + depends_on: + static-0: + condition: service_healthy + static-init: + condition: service_completed_successfully + tenant-init: + condition: service_completed_successfully + + database-3: + <<: *ydb-node + container_name: ydb-database-3 + hostname: database-3 + command: + - /opt/ydb/bin/ydbd + - server + - --grpc-port + - "2138" + - --mon-port + - "8768" + - --ic-port + - "19004" + - --yaml-config + - /opt/ydb/cfg/config.yaml + - --tenant + - /Root/testdb + - --node-broker + - grpc://static-0:2135 + - --label + - deployment=docker + ports: + - "2138:2138" + - "8768:8768" + healthcheck: + test: bash -c "exec 6<> /dev/tcp/localhost/2138" + interval: 10s + timeout: 1s + retries: 3 + start_period: 30s + depends_on: + static-0: + condition: service_healthy + static-init: + condition: service_completed_successfully + tenant-init: + condition: service_completed_successfully + + database-4: + <<: *ydb-node + container_name: ydb-database-4 + hostname: database-4 + command: + - /opt/ydb/bin/ydbd + - server + - --grpc-port + - "2139" + - --mon-port + - "8769" + - --ic-port + - "19005" + - --yaml-config + - /opt/ydb/cfg/config.yaml + - --tenant + - /Root/testdb + - --node-broker + - grpc://static-0:2135 + - --label + - deployment=docker + ports: + - "2139:2139" + - "8769:8769" + healthcheck: + test: bash -c "exec 6<> /dev/tcp/localhost/2139" + interval: 10s + timeout: 1s + retries: 3 + start_period: 30s + depends_on: + static-0: + condition: service_healthy + static-init: + condition: service_completed_successfully + tenant-init: + condition: service_completed_successfully + + database-5: + <<: *ydb-node + container_name: ydb-database-5 + hostname: database-5 + command: + - /opt/ydb/bin/ydbd + - server + - --grpc-port + - "2140" + - --mon-port + - "8770" + - --ic-port + - "19006" + - --yaml-config + - /opt/ydb/cfg/config.yaml + - --tenant + - /Root/testdb + - --node-broker + - grpc://static-0:2135 + - --label + - deployment=docker + ports: + - "2140:2140" + - "8770:8770" + healthcheck: + test: bash -c "exec 6<> /dev/tcp/localhost/2140" + interval: 10s + timeout: 1s + retries: 3 + start_period: 30s + depends_on: + static-0: + condition: service_healthy + static-init: + condition: service_completed_successfully + tenant-init: + condition: service_completed_successfully + + app-with-retry: + build: + context: ../../../.. + dockerfile: spring-ydb-retry/slo/Dockerfile + container_name: ydb-app-with-retry + platform: linux/amd64 + networks: + - slo-network + environment: + SERVER_PORT: "8081" + SPRING_DATASOURCE_URL: jdbc:ydb:grpc://static-0:2135/Root/testdb + SPRING_DATASOURCE_DRIVER_CLASS_NAME: tech.ydb.jdbc.YdbDriver + YDB_TRANSACTION_RETRY_ENABLED: "true" + YDB_TRANSACTION_RETRY_MAX_RETRIES: "10" + YDB_TRANSACTION_RETRY_IDEMPOTENT: "true" + REF: with-retry + SLO_RUN_ID: ${SLO_RUN_ID:-} + SLO_RESULTS_DIR: /app/results + SLO_READ_RPS: "100" + SLO_WRITE_RPS: "100" + SLO_INITIAL_DATA: "1000" + SLO_TIME: "600" + volumes: + - ../results:/app/results + depends_on: + static-0: + condition: service_healthy + + app-no-retry: + build: + context: ../../../.. + dockerfile: spring-ydb-retry/slo/Dockerfile + container_name: ydb-app-no-retry + platform: linux/amd64 + networks: + - slo-network + environment: + SERVER_PORT: "8082" + SPRING_DATASOURCE_URL: jdbc:ydb:grpc://static-0:2135/Root/testdb + SPRING_DATASOURCE_DRIVER_CLASS_NAME: tech.ydb.jdbc.YdbDriver + YDB_TRANSACTION_RETRY_ENABLED: "false" + REF: no-retry + SLO_RUN_ID: ${SLO_RUN_ID:-} + SLO_RESULTS_DIR: /app/results + SLO_READ_RPS: "100" + SLO_WRITE_RPS: "100" + SLO_INITIAL_DATA: "1000" + SLO_TIME: "600" + volumes: + - ../results:/app/results + depends_on: + static-0: + condition: service_healthy + + chaos: + image: docker:latest + restart: on-failure + container_name: ydb-chaos + platform: linux/amd64 + networks: + - slo-network + entrypoint: ["/bin/sh", "-c", "chmod +x /opt/ydb/chaos.sh && /opt/ydb/chaos.sh"] + volumes: + - ./chaos.sh:/opt/ydb/chaos.sh + - ../configs/ydb.yaml:/opt/ydb/cfg/config.yaml + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + static-0: + condition: service_healthy + + prometheus: + image: prom/prometheus:v3.3.1 + container_name: prometheus + networks: + - slo-network + volumes: + - ../configs/prometheus:/etc/prometheus + - ../data/prometheus:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yaml' + - '--storage.tsdb.path=/prometheus' + - '--storage.tsdb.retention.time=200h' + - '--web.enable-lifecycle' + - '--web.enable-otlp-receiver' + ports: + - "9090:9090" + + grafana: + image: grafana/grafana:9.5.3 + container_name: grafana + networks: + - slo-network + volumes: + - ../configs/grafana/provisioning:/etc/grafana/provisioning + - ../data/grafana:/var/lib/grafana + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + ports: + - "3000:3000" diff --git a/spring-ydb/spring-ydb-retry/slo/playground/configs/grafana/provisioning/dashboards/dashboard.yaml b/spring-ydb/spring-ydb-retry/slo/playground/configs/grafana/provisioning/dashboards/dashboard.yaml new file mode 100644 index 00000000..c8442c2f --- /dev/null +++ b/spring-ydb/spring-ydb-retry/slo/playground/configs/grafana/provisioning/dashboards/dashboard.yaml @@ -0,0 +1,9 @@ +apiVersion: 1 +providers: + - name: 'SLO' + folder: '' + type: file + disableDeletion: false + editable: true + options: + path: /etc/grafana/provisioning/dashboards diff --git a/spring-ydb/spring-ydb-retry/slo/playground/configs/grafana/provisioning/dashboards/slo.json b/spring-ydb/spring-ydb-retry/slo/playground/configs/grafana/provisioning/dashboards/slo.json new file mode 100644 index 00000000..6e608796 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/slo/playground/configs/grafana/provisioning/dashboards/slo.json @@ -0,0 +1,638 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "links": [], + "panels": [ + { + "title": "Error Rate (%)", + "type": "stat", + "gridPos": { + "h": 6, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "orientation": "horizontal", + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum by (ref) (rate(slo_operations_total{status=\"failure\"}[1m])) / sum by (ref) (rate(slo_operations_total[1m])) * 100", + "legendFormat": "{{ref}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "red", + "value": 20 + } + ] + }, + "mappings": [] + }, + "overrides": [] + } + }, + { + "title": "Total Errors (cumulative)", + "type": "stat", + "gridPos": { + "h": 6, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 2, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "orientation": "horizontal", + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum by (ref) (increase(slo_operations_total{status=\"failure\"}[$__range]))", + "legendFormat": "{{ref}}" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 100 + } + ] + } + }, + "overrides": [] + } + }, + { + "title": "Total Successes (cumulative)", + "type": "stat", + "gridPos": { + "h": 6, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 3, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "orientation": "horizontal", + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum by (ref) (increase(slo_operations_total{status=\"success\"}[$__range]))", + "legendFormat": "{{ref}}" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1000 + } + ] + } + }, + "overrides": [] + } + }, + { + "title": "Error Reduction from Retry", + "type": "stat", + "gridPos": { + "h": 6, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 4, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "orientation": "horizontal", + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "(sum(increase(slo_operations_total{ref=\"no-retry\",status=\"failure\"}[$__range])) - sum(increase(slo_operations_total{ref=\"with-retry\",status=\"failure\"}[$__range]))) / sum(increase(slo_operations_total{ref=\"no-retry\",status=\"failure\"}[$__range])) * 100", + "legendFormat": "error reduction %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 50 + } + ] + } + }, + "overrides": [] + } + }, + { + "title": "Failed Operations / sec", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 6 + }, + "id": 5, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum by (ref) (rate(slo_operations_total{status=\"failure\"}[1m]))", + "legendFormat": "{{ref}}" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "fillOpacity": 30, + "lineWidth": 2 + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*no-retry.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*with-retry.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + } + ] + } + }, + { + "title": "Operations / sec (by type and status)", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 6 + }, + "id": 7, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum by (ref, operation_type, status) (rate(slo_operations_total[1m]))", + "legendFormat": "{{ref}} {{operation_type}} {{status}}" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "fillOpacity": 20, + "lineWidth": 1 + } + }, + "overrides": [] + } + }, + { + "title": "Operation Latency p99 (seconds)", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 14 + }, + "id": 8, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.99, sum by (ref, le) (rate(slo_operation_duration_seconds_bucket[1m])))", + "legendFormat": "{{ref}} p99" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.95, sum by (ref, le) (rate(slo_operation_duration_seconds_bucket[1m])))", + "legendFormat": "{{ref}} p95" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.50, sum by (ref, le) (rate(slo_operation_duration_seconds_bucket[1m])))", + "legendFormat": "{{ref}} p50" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "fillOpacity": 10, + "lineWidth": 2 + }, + "unit": "s" + }, + "overrides": [] + } + }, + { + "title": "Failed Ops / sec by Error Type (no-retry)", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 22 + }, + "id": 10, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum by (error_type) (rate(slo_operations_total{ref=\"no-retry\",status=\"failure\",error_type!=\"none\"}[1m]))", + "legendFormat": "no-retry {{error_type}}" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "fillOpacity": 30, + "lineWidth": 2 + } + }, + "overrides": [] + } + }, + { + "title": "Failed Ops / sec by Error Type (with-retry)", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 22 + }, + "id": 11, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum by (error_type) (rate(slo_operations_total{ref=\"with-retry\",status=\"failure\",error_type!=\"none\"}[1m]))", + "legendFormat": "with-retry {{error_type}}" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "fillOpacity": 30, + "lineWidth": 2 + } + }, + "overrides": [] + } + }, + { + "title": "Error Type Distribution (no-retry)", + "type": "piechart", + "gridPos": { + "h": 8, + "w": 6, + "x": 0, + "y": 30 + }, + "id": 12, + "options": { + "legend": { + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + }, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum by (error_type) (increase(slo_operations_total{ref=\"no-retry\",status=\"failure\",error_type!=\"none\"}[$__range]))", + "legendFormat": "{{error_type}}" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + } + }, + { + "title": "Error Type Distribution (with-retry)", + "type": "piechart", + "gridPos": { + "h": 8, + "w": 6, + "x": 6, + "y": 30 + }, + "id": 13, + "options": { + "legend": { + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + }, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum by (error_type) (increase(slo_operations_total{ref=\"with-retry\",status=\"failure\",error_type!=\"none\"}[$__range]))", + "legendFormat": "{{error_type}}" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + } + }, + { + "title": "Error Rate by Type (%) — Comparison", + "type": "table", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 30 + }, + "id": 14, + "options": { + "showHeader": true, + "footer": { + "show": true, + "reducer": ["sum"] + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum by (ref, error_type) (increase(slo_operations_total{status=\"failure\",error_type!=\"none\"}[$__range]))", + "format": "table", + "instant": true + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true + }, + "renameByName": { + "Value": "Errors", + "error_type": "Error Type", + "ref": "Version" + } + } + } + ], + "fieldConfig": { + "defaults": {}, + "overrides": [] + } + } + ], + "refresh": "5s", + "schemaVersion": 39, + "tags": [ + "slo", + "ydb", + "retry" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-30m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "YDB Spring Retry SLO - Retry vs No-Retry Comparison", + "uid": "ydb-slo-retry", + "version": 2 +} diff --git a/spring-ydb/spring-ydb-retry/slo/playground/configs/grafana/provisioning/datasources/datasource.yaml b/spring-ydb/spring-ydb-retry/slo/playground/configs/grafana/provisioning/datasources/datasource.yaml new file mode 100644 index 00000000..415d5684 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/slo/playground/configs/grafana/provisioning/datasources/datasource.yaml @@ -0,0 +1,8 @@ +apiVersion: 1 +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + uid: prometheus diff --git a/spring-ydb/spring-ydb-retry/slo/playground/configs/prometheus/prometheus.yaml b/spring-ydb/spring-ydb-retry/slo/playground/configs/prometheus/prometheus.yaml new file mode 100644 index 00000000..fb4e212f --- /dev/null +++ b/spring-ydb/spring-ydb-retry/slo/playground/configs/prometheus/prometheus.yaml @@ -0,0 +1,16 @@ +global: + scrape_interval: 1s + evaluation_interval: 1s + +scrape_configs: + - job_name: 'app-with-retry' + static_configs: + - targets: ['app-with-retry:9464'] + metrics_path: '/metrics' + scrape_interval: 5s + + - job_name: 'app-no-retry' + static_configs: + - targets: ['app-no-retry:9464'] + metrics_path: '/metrics' + scrape_interval: 5s diff --git a/spring-ydb/spring-ydb-retry/slo/playground/configs/ydb.yaml b/spring-ydb/spring-ydb-retry/slo/playground/configs/ydb.yaml new file mode 100644 index 00000000..ff3f4f3b --- /dev/null +++ b/spring-ydb/spring-ydb-retry/slo/playground/configs/ydb.yaml @@ -0,0 +1,63 @@ +pqconfig: + require_credentials_in_new_protocol: false + +actor_system_config: + cpu_count: 1 + node_type: STORAGE + use_auto_config: true +blob_storage_config: + service_set: + groups: + - erasure_species: none + rings: + - fail_domains: + - vdisk_locations: + - node_id: 1 + path: SectorMap:1:64 + pdisk_category: SSD +channel_profile_config: + profile: + - channel: + - erasure_species: none + pdisk_category: 0 + storage_pool_kind: ssd + - erasure_species: none + pdisk_category: 0 + storage_pool_kind: ssd + - erasure_species: none + pdisk_category: 0 + storage_pool_kind: ssd + profile_id: 0 +domains_config: + domain: + - name: Root + storage_pool_types: + - kind: ssd + pool_config: + box_id: 1 + erasure_species: none + kind: ssd + pdisk_filter: + - property: + - type: SSD + vdisk_kind: Default + state_storage: + - ring: + node: [ 1 ] + nto_select: 1 + ssid: 1 +host_configs: + - drive: + - path: SectorMap:1:64 + type: SSD + host_config_id: 1 +hosts: + - host: static-0 + host_config_id: 1 + node_id: 1 + port: 19001 + walle_location: + body: 1 + data_center: az-1 + rack: "0" +static_erasure: none diff --git a/spring-ydb/spring-ydb-retry/slo/pom.xml b/spring-ydb/spring-ydb-retry/slo/pom.xml new file mode 100644 index 00000000..eed68110 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/slo/pom.xml @@ -0,0 +1,92 @@ + + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.4.0 + + + + tech.ydb + ydb-slo-workload + 1.0.0-SNAPSHOT + jar + + YDB SLO Workload + + + 21 + UTF-8 + 1.43.0 + + + + + + io.opentelemetry + opentelemetry-bom + ${opentelemetry.version} + pom + import + + + io.opentelemetry + opentelemetry-bom-alpha + ${opentelemetry.version}-alpha + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-jdbc + + + tech.ydb.jdbc + ydb-jdbc-driver + 2.3.22 + + + tech.ydb + spring-ydb-retry + 1.0.0-SNAPSHOT + + + io.opentelemetry + opentelemetry-api + + + io.opentelemetry + opentelemetry-sdk + + + io.opentelemetry + opentelemetry-exporter-otlp + + + io.opentelemetry + opentelemetry-exporter-prometheus + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + tech.ydb.slo.SloApplication + exec + + + + + diff --git a/spring-ydb/spring-ydb-retry/slo/src/README.md b/spring-ydb/spring-ydb-retry/slo/src/README.md new file mode 100644 index 00000000..3bc93ee2 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/slo/src/README.md @@ -0,0 +1,143 @@ +# SLO Workload Application + +A Spring Boot load-generation tool that drives read/write traffic against a YDB table and exposes +metrics to Prometheus via OpenTelemetry. Used to measure how the **spring-ydb-retry** library +reduces visible application errors under cluster-level fault injection. + +## Quick Start + +### 1. Build + +```bash +cd slo +mvn package -DskipTests +``` + +### 2. Run + +Two instances are typically launched side-by-side — one with retry enabled and one without: + +```bash +# With retry +java -jar target/ydb-slo-workload-1.0.0-SNAPSHOT-exec.jar \ + --server.port=8081 \ + --spring.datasource.url=jdbc:ydb:grpc://localhost:2136/Root/testdb \ + --ydb.transaction.retry.enabled=true \ + --ydb.transaction.retry.max-retries=10 \ + --ydb.transaction.retry.idempotent=true \ + --slo.ref=with-retry + +# Without retry +java -jar target/ydb-slo-workload-1.0.0-SNAPSHOT-exec.jar \ + --server.port=8082 \ + --spring.datasource.url=jdbc:ydb:grpc://localhost:2136/Root/testdb \ + --ydb.transaction.retry.enabled=false \ + --slo.ref=no-retry +``` + +In the Docker Compose playground, both instances are launched automatically — see +[`playground/README.md`](../playground/README.md). + +## Lifecycle + +On startup the application (`SloRunner`, a `CommandLineRunner`) performs three steps: + +1. **Create table** — creates `slo_test_table` (up to 10 attempts with retry between each) +2. **Seed data** — populates `initialDataCount` rows via single-row UPSERTs +3. **Run workload** — starts concurrent read and write jobs for `runTimeSeconds` + +After the workload finishes, the app stays alive to serve Prometheus metrics on port 9464. + +## Table Schema + +```sql +CREATE TABLE slo_test_table ( + guid Text, + id Int32, + payload_str Text, + payload_double Double, + payload_timestamp Timestamp, + PRIMARY KEY (guid, id) +); +``` + +## What the Workload Does + +- **Read job** —reads rows by random IDs (keys generated by writeJob) +- **Write job** — generates and upserts new rows + +## Configuration + +All parameters are set via environment variables (or Spring Boot command-line arguments). + +### Application + +| Variable | Default | Description | +|---|---|---| +| `SERVER_PORT` | `8080` | HTTP port (Actuator endpoints) | +| `SPRING_DATASOURCE_URL` | `jdbc:ydb:grpc://localhost:2136/Root/testdb` | YDB JDBC URL | +| `YDB_TRANSACTION_RETRY_ENABLED` | `true` | Enable/disable retry | +| `YDB_TRANSACTION_RETRY_MAX_RETRIES` | `10` | Max retry attempts | +| `YDB_TRANSACTION_RETRY_IDEMPOTENT` | `true` | Treat operations as idempotent | +| `SLO_RUN_ID` | auto | Shared run identifier used for the result folder name | +| `SLO_RESULTS_DIR` | `results` | Root directory where per-run result folders are stored | + +### Workload + +| Variable | Default | Description | +|---|---|---| +| `SLO_READ_RPS` | `100` | Target read requests per second | +| `SLO_WRITE_RPS` | `100` | Target write requests per second | +| `SLO_INITIAL_DATA` | `1000` | Number of rows to pre-populate | +| `SLO_TIME` | `600` | Total run duration (seconds) | +| `REF` | `unknown` | Instance label for metrics (`with-retry` / `no-retry`) | + +## Saved Results + +```text +/ + / + retry + no-retry +``` + +The `retry` file is written by the `with-retry` instance, and `no-retry` is written by the `no-retry` instance. + +## Collected Metrics (exposed via OpenTelemetry on :9464) + +| Metric | Type | Labels | Description | +|---|---|---|---| +| `slo_operations_total` | Counter | ref, operation_type, status, error_type | Total number of operations | +| `slo_operation_duration_seconds` | Histogram | ref, operation_type, status, error_type | Operation latency (seconds) | + +### Labels + +| Label | Values | Description | +|---|---|---| +| `ref` | `with-retry`, `no-retry` | Instance identifier | +| `operation_type` | `read`, `write` | Operation type | +| `status` | `success`, `failure` | Operation result | +| `error_type` | `none`, `UNAVAILABLE`, `TRANSPORT_UNAVAILABLE`, `OVERLOADED`, `BAD_SESSION`, … | YDB status code or exception class name | + +### Error Classification + +`extractErrorType` walks the exception cause chain looking for `YdbStatusable`. If found, returns +`Status.Code.name()` (e.g. `UNAVAILABLE`, `TRANSPORT_UNAVAILABLE`). Otherwise returns the +exception class name (e.g. `SqlTransientException`). + +## Classes + +| Class | Description | +|---|---| +| `SloApplication` | `@SpringBootApplication` entry point with `@EnableConfigurationProperties(SloConfig.class)` | +| `SloConfig` | `@ConfigurationProperties(prefix = "slo")` — binds workload parameters | +| `SloService` | `@YdbTransactional` service: `upsert()`, `upsert2()`, `select()`, `selectMaxId()` | +| `SloRunner` | `CommandLineRunner` — table creation, data seeding, load generation, metrics | +| `OtelConfig` | OpenTelemetry SDK bean — `PrometheusHttpServer` on port 9464 | + + +## Grafana Dashboard + +Import the pre-built SLO dashboard from +[`playground/configs/grafana/provisioning/dashboards/slo.json`](../playground/configs/grafana/provisioning/dashboards/slo.json) +into your Grafana instance to visualize the collected metrics. diff --git a/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/OtelConfig.java b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/OtelConfig.java new file mode 100644 index 00000000..fb5e8749 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/OtelConfig.java @@ -0,0 +1,28 @@ +package tech.ydb.slo; + +import io.opentelemetry.exporter.prometheus.PrometheusHttpServer; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OtelConfig { + + private static final int PROMETHEUS_PORT = 9464; + + @Bean(destroyMethod = "close") + public OpenTelemetrySdk openTelemetry() { + PrometheusHttpServer prometheusHttpServer = PrometheusHttpServer.builder() + .setPort(PROMETHEUS_PORT) + .build(); + + SdkMeterProvider meterProvider = SdkMeterProvider.builder() + .registerMetricReader(prometheusHttpServer) + .build(); + + return OpenTelemetrySdk.builder() + .setMeterProvider(meterProvider) + .build(); + } +} diff --git a/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloApplication.java b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloApplication.java new file mode 100644 index 00000000..182b1edf --- /dev/null +++ b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloApplication.java @@ -0,0 +1,13 @@ +package tech.ydb.slo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +@SpringBootApplication +@EnableConfigurationProperties(SloConfig.class) +public class SloApplication { + public static void main(String[] args) { + SpringApplication.run(SloApplication.class, args); + } +} diff --git a/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloConfig.java b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloConfig.java new file mode 100644 index 00000000..9d87eea5 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloConfig.java @@ -0,0 +1,71 @@ +package tech.ydb.slo; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "slo") +public class SloConfig { + + private int readRps = 100; + private int writeRps = 100; + private int initialDataCount = 1000; + private int runTimeSeconds = 600; + private String ref = "unknown"; + private String runId = ""; + private String resultsDir = "results"; + + public int getReadRps() { + return readRps; + } + + public void setReadRps(int readRps) { + this.readRps = readRps; + } + + public int getWriteRps() { + return writeRps; + } + + public void setWriteRps(int writeRps) { + this.writeRps = writeRps; + } + + public int getInitialDataCount() { + return initialDataCount; + } + + public void setInitialDataCount(int initialDataCount) { + this.initialDataCount = initialDataCount; + } + + public int getRunTimeSeconds() { + return runTimeSeconds; + } + + public void setRunTimeSeconds(int runTimeSeconds) { + this.runTimeSeconds = runTimeSeconds; + } + + public String getRef() { + return ref; + } + + public void setRef(String ref) { + this.ref = ref; + } + + public String getRunId() { + return runId; + } + + public void setRunId(String runId) { + this.runId = runId; + } + + public String getResultsDir() { + return resultsDir; + } + + public void setResultsDir(String resultsDir) { + this.resultsDir = resultsDir; + } +} diff --git a/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloResultWriter.java b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloResultWriter.java new file mode 100644 index 00000000..555cc9e3 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloResultWriter.java @@ -0,0 +1,210 @@ +package tech.ydb.slo; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import tech.ydb.retry.YdbRetryProperties; + +@Component +public class SloResultWriter { + + private static final Logger log = LoggerFactory.getLogger(SloResultWriter.class); + private static final String CURRENT_RUN_ID_FILE = ".current-run-id"; + private static final String RUN_ID_PREFIX = "run-"; + private static final String RUN_ID_TIMESTAMP_PATTERN = "yyyyMMdd-HHmmss"; + private static final int RUN_ID_RANDOM_SUFFIX_LENGTH = 8; + private static final String WITH_RETRY_REF = "with-retry"; + private static final String NO_RETRY_REF = "no-retry"; + private static final String RETRY_RESULT_FILE_NAME = "retry"; + private static final String NO_RETRY_RESULT_FILE_NAME = "no-retry"; + private static final String FILE_NAME_SANITIZE_REGEX = "[^a-zA-Z0-9._-]"; + private static final String FILE_NAME_SANITIZE_REPLACEMENT = "_"; + + public String resolveRunId(SloConfig config, Instant startedAt) { + if (config.getRunId() != null && !config.getRunId().isBlank()) { + return config.getRunId(); + } + + Path resultsRoot = resultsRoot(config); + try { + Files.createDirectories(resultsRoot); + Path currentRunIdFile = resultsRoot.resolve(CURRENT_RUN_ID_FILE); + try (FileChannel channel = FileChannel.open( + currentRunIdFile, + StandardOpenOption.CREATE, + StandardOpenOption.READ, + StandardOpenOption.WRITE + ); FileLock ignored = channel.lock()) { + String existingRunId = readCurrentRunId(channel); + if (!existingRunId.isBlank() && isReusableRun(resultsRoot.resolve(existingRunId))) { + return existingRunId; + } + + String generatedRunId = generateRunId(startedAt); + writeCurrentRunId(channel, generatedRunId); + return generatedRunId; + } + } catch (IOException exception) { + throw new RuntimeException("Failed to resolve shared SLO runId", exception); + } + } + + public void writeSummary(SloConfig config, YdbRetryProperties retryProperties, RunSummary summary) { + Path runDirectory = resultsRoot(config).resolve(summary.runId()); + Path resultFile = runDirectory.resolve(resultFileName(config.getRef())); + + try { + Files.createDirectories(runDirectory); + Files.writeString( + resultFile, + buildRunSummaryText(config, retryProperties, summary), + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE + ); + log.info( + "SLO run result file written: runId={}, ref={}, path={}", + summary.runId(), + config.getRef(), + resultFile.toAbsolutePath() + ); + } catch (IOException exception) { + throw new RuntimeException("Failed to write SLO run summary file", exception); + } + } + + public Path resultsRoot(SloConfig config) { + return Path.of(config.getResultsDir()); + } + + private String generateRunId(Instant startedAt) { + String timestamp = DateTimeFormatter.ofPattern(RUN_ID_TIMESTAMP_PATTERN) + .withZone(ZoneOffset.UTC) + .format(startedAt); + return RUN_ID_PREFIX + timestamp + "-" + UUID.randomUUID().toString().substring(0, RUN_ID_RANDOM_SUFFIX_LENGTH); + } + + private String buildRunSummaryText(SloConfig config, + YdbRetryProperties retryProperties, + RunSummary summary) { + StringBuilder builder = new StringBuilder(); + builder.append("runId: ").append(summary.runId()).append('\n'); + builder.append("ref: ").append(config.getRef()).append('\n'); + builder.append("startedAt: ").append(summary.startedAt()).append('\n'); + builder.append("finishedAt: ").append(summary.finishedAt()).append('\n'); + builder.append("durationMs: ") + .append(Duration.between(summary.startedAt(), summary.finishedAt()).toMillis()) + .append('\n'); + builder.append("resultsDir: ").append(resultsRoot(config).toAbsolutePath()).append('\n'); + builder.append('\n'); + builder.append("readRps: ").append(config.getReadRps()).append('\n'); + builder.append("writeRps: ").append(config.getWriteRps()).append('\n'); + builder.append("initialDataCount: ").append(config.getInitialDataCount()).append('\n'); + builder.append("runTimeSeconds: ").append(config.getRunTimeSeconds()).append('\n'); + builder.append('\n'); + builder.append("retryEnabled: ").append(retryProperties.isEnabled()).append('\n'); + builder.append("retryMaxRetries: ").append(retryProperties.getMaxRetries()).append('\n'); + builder.append("retryIdempotent: ").append(retryProperties.isIdempotent()).append('\n'); + builder.append("retrySlowBackoffBaseMs: ").append(retryProperties.getSlowBackoffBaseMs()).append('\n'); + builder.append("retryFastBackoffBaseMs: ").append(retryProperties.getFastBackoffBaseMs()).append('\n'); + builder.append("retrySlowCapBackoffMs: ").append(retryProperties.getSlowCapBackoffMs()).append('\n'); + builder.append("retryFastCapBackoffMs: ").append(retryProperties.getFastCapBackoffMs()).append('\n'); + builder.append('\n'); + builder.append("totalOperations: ").append(summary.totalOperations()).append('\n'); + builder.append("totalSuccess: ").append(summary.totalSuccess()).append('\n'); + builder.append("totalFailure: ").append(summary.totalFailure()).append('\n'); + builder.append("failureRatePercent: ").append(summary.failureRatePercent()).append('\n'); + builder.append("readSuccess: ").append(summary.readSuccess()).append('\n'); + builder.append("readFailure: ").append(summary.readFailure()).append('\n'); + builder.append("writeSuccess: ").append(summary.writeSuccess()).append('\n'); + builder.append("writeFailure: ").append(summary.writeFailure()).append('\n'); + builder.append('\n'); + builder.append("overallP50Ms: ").append(summary.overallP50()).append('\n'); + builder.append("overallP95Ms: ").append(summary.overallP95()).append('\n'); + builder.append("overallP99Ms: ").append(summary.overallP99()).append('\n'); + builder.append("readP50Ms: ").append(summary.readP50()).append('\n'); + builder.append("readP95Ms: ").append(summary.readP95()).append('\n'); + builder.append("readP99Ms: ").append(summary.readP99()).append('\n'); + builder.append("writeP50Ms: ").append(summary.writeP50()).append('\n'); + builder.append("writeP95Ms: ").append(summary.writeP95()).append('\n'); + builder.append("writeP99Ms: ").append(summary.writeP99()).append('\n'); + builder.append('\n'); + builder.append("errorTypes:").append('\n'); + if (summary.errorCounts().isEmpty()) { + builder.append(" none").append('\n'); + } else { + summary.errorCounts().forEach((errorType, count) -> + builder.append(" ").append(errorType).append(": ").append(count).append('\n')); + } + return builder.toString(); + } + + private static String resultFileName(String ref) { + if (WITH_RETRY_REF.equals(ref)) { + return RETRY_RESULT_FILE_NAME; + } + if (NO_RETRY_REF.equals(ref)) { + return NO_RETRY_RESULT_FILE_NAME; + } + return ref.replaceAll(FILE_NAME_SANITIZE_REGEX, FILE_NAME_SANITIZE_REPLACEMENT); + } + + private static boolean isReusableRun(Path runDirectory) { + return !Files.exists(runDirectory.resolve(RETRY_RESULT_FILE_NAME)) + && !Files.exists(runDirectory.resolve(NO_RETRY_RESULT_FILE_NAME)); + } + + private static String readCurrentRunId(FileChannel channel) throws IOException { + channel.position(0); + ByteBuffer buffer = ByteBuffer.allocate((int) channel.size()); + channel.read(buffer); + buffer.flip(); + return StandardCharsets.UTF_8.decode(buffer).toString().trim(); + } + + private static void writeCurrentRunId(FileChannel channel, String runId) throws IOException { + channel.truncate(0); + channel.position(0); + channel.write(StandardCharsets.UTF_8.encode(runId)); + channel.force(true); + } + + public record RunSummary( + String runId, + Instant startedAt, + Instant finishedAt, + long totalOperations, + long totalSuccess, + long totalFailure, + String failureRatePercent, + long readSuccess, + long readFailure, + long writeSuccess, + long writeFailure, + String overallP50, + String overallP95, + String overallP99, + String readP50, + String readP95, + String readP99, + String writeP50, + String writeP95, + String writeP99, + Map errorCounts + ) { + } +} diff --git a/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloRunner.java b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloRunner.java new file mode 100644 index 00000000..6d809130 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloRunner.java @@ -0,0 +1,356 @@ +package tech.ydb.slo; + +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.DoubleHistogram; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.Meter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.CommandLineRunner; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import tech.ydb.core.Status; +import tech.ydb.jdbc.exception.YdbStatusable; +import tech.ydb.retry.YdbRetryProperties; + +import java.time.Instant; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +@Component +public class SloRunner implements CommandLineRunner { + + private static final Logger log = LoggerFactory.getLogger(SloRunner.class); + private static final String OPERATIONS_METRIC_NAME = "slo.operations"; + private static final String DURATION_METRIC_NAME = "slo.operation.duration.seconds"; + private static final String DURATION_METRIC_UNIT = "s"; + private static final List DURATION_BUCKETS = List.of( + 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, + 1.0, 2.5, 5.0, 10.0, 30.0 + ); + + private static final String TABLE_NAME = "slo_test_table"; + private static final String READ_OPERATION = "read"; + private static final String WRITE_OPERATION = "write"; + private static final String SUCCESS_STATUS = "success"; + private static final String FAILURE_STATUS = "failure"; + private static final String NO_ERROR_TYPE = "none"; + + private final JdbcTemplate jdbcTemplate; + private final SloService sloService; + private final SloConfig config; + private final YdbRetryProperties retryProperties; + private final SloResultWriter resultWriter; + private final LongCounter operationsCounter; + private final DoubleHistogram durationHistogram; + private final SloStats sloStats = new SloStats(); + + private final AtomicInteger nextId = new AtomicInteger(0); + private final List readableIds = Collections.synchronizedList(new ArrayList<>()); + + private static final AttributeKey REF_KEY = AttributeKey.stringKey("ref"); + private static final AttributeKey OP_TYPE_KEY = AttributeKey.stringKey("operation_type"); + private static final AttributeKey STATUS_KEY = AttributeKey.stringKey("status"); + private static final AttributeKey ERROR_TYPE_KEY = AttributeKey.stringKey("error_type"); + + public SloRunner(JdbcTemplate jdbcTemplate, SloService sloService, SloConfig config, + YdbRetryProperties retryProperties, SloResultWriter resultWriter, + OpenTelemetry openTelemetry) { + this.jdbcTemplate = jdbcTemplate; + this.sloService = sloService; + this.config = config; + this.retryProperties = retryProperties; + this.resultWriter = resultWriter; + + Meter meter = openTelemetry.getMeter("slo"); + this.operationsCounter = meter.counterBuilder(OPERATIONS_METRIC_NAME) + .setDescription("Total number of SLO operations") + .build(); + this.durationHistogram = meter.histogramBuilder(DURATION_METRIC_NAME) + .setDescription("SLO operation latency") + .setUnit(DURATION_METRIC_UNIT) + .setExplicitBucketBoundariesAdvice(DURATION_BUCKETS) + .build(); + } + + @Override + public void run(String... args) { + Instant startedAt = Instant.now(); + String runId = resultWriter.resolveRunId(config, startedAt); + createTable(); + seedData(); + runWorkload(runId); + Instant finishedAt = Instant.now(); + writeRunSummaryFile(runId, startedAt, finishedAt); + waitForPrometheusScrapes(runId); + log.info("SLO workload completed and final metrics were exposed for scraping: runId={}", runId); + } + + private void createTable() { + for (int attempt = 0; attempt < 10; attempt++) { + try { + jdbcTemplate.execute( + "CREATE TABLE " + TABLE_NAME + " (" + + "guid Text, " + + "id Int32, " + + "payload_str Text, " + + "payload_double Double, " + + "payload_timestamp Timestamp, " + + "PRIMARY KEY (guid, id)" + + ")" + ); + log.info("Created table {}", TABLE_NAME); + return; + } catch (Exception e) { + String msg = e.getMessage(); + if (msg != null && (msg.contains("already exists") || msg.contains("ALREADY_EXISTS") || msg.contains("path exist"))) { + log.info("Table slo_test_table already exists"); + return; + } + log.warn("Failed to create table (attempt {}/{}): {}", attempt + 1, 10, msg); + if (attempt == 9) { + log.warn("Max attempts reached, proceeding anyway"); + return; + } + try { + Thread.sleep((attempt + 1) * 1000L); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RuntimeException(ie); + } + } + } + } + + private void seedData() { + log.info("Seeding {} initial rows...", config.getInitialDataCount()); + int success = 0; + for (int i = 1; i <= config.getInitialDataCount(); i++) { + try { + String guid = guidFromInt(i); + String payload = randomString(); + sloService.upsert(guid, i, payload, Math.random(), LocalDateTime.now()); + registerReadableId(i); + success++; + } catch (Exception e) { + log.warn("Failed to seed row {}: {}", i, e.getMessage()); + } + } + nextId.set(config.getInitialDataCount()); + log.info("Seeded {}/{} rows", success, config.getInitialDataCount()); + } + + private void runWorkload(String runId) { + String ref = config.getRef(); + log.info("Starting workload: runId={}, ref={}, readRps={}, writeRps={}, time={}s", + runId, ref, config.getReadRps(), config.getWriteRps(), config.getRunTimeSeconds()); + + ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); + ExecutorService workers = Executors.newFixedThreadPool(20); + + int intervalMs = 100; + int readsPerInterval = Math.max(1, config.getReadRps() / 10); + int writesPerInterval = Math.max(1, config.getWriteRps() / 10); + + ScheduledFuture readFuture = scheduler.scheduleAtFixedRate(() -> { + for (int i = 0; i < readsPerInterval; i++) { + workers.submit(() -> doRead(ref)); + } + }, 0, intervalMs, TimeUnit.MILLISECONDS); + + ScheduledFuture writeFuture = scheduler.scheduleAtFixedRate(() -> { + for (int i = 0; i < writesPerInterval; i++) { + workers.submit(() -> doWrite(ref)); + } + }, 0, intervalMs, TimeUnit.MILLISECONDS); + + try { + Thread.sleep(TimeUnit.SECONDS.toMillis(config.getRunTimeSeconds())); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + throw new RuntimeException("SLO workload interrupted", interruptedException); + } + + readFuture.cancel(false); + writeFuture.cancel(false); + scheduler.shutdown(); + workers.shutdown(); + awaitTermination("scheduler", scheduler, 30L, TimeUnit.SECONDS); + awaitTermination("workers", workers, 30L, TimeUnit.SECONDS); + log.info("Workload finished: runId={}, ref={}", runId, ref); + } + + private void doWrite(String ref) { + int id = nextId.incrementAndGet(); + String guid = guidFromInt(id); + String payload = randomString(); + double payloadDouble = Math.random(); + LocalDateTime ts = LocalDateTime.now(); + + long start = System.nanoTime(); + try { + sloService.upsert2(guid, id, payload, payloadDouble, ts); + registerReadableId(id); + long durationNanos = System.nanoTime() - start; + sloStats.recordSuccess(WRITE_OPERATION, durationNanos); + recordLatency(ref, WRITE_OPERATION, SUCCESS_STATUS, NO_ERROR_TYPE, durationNanos); + incrementCounter(ref, WRITE_OPERATION, SUCCESS_STATUS, NO_ERROR_TYPE); + } catch (Exception e) { + String errorType = extractErrorType(e); + long durationNanos = System.nanoTime() - start; + sloStats.recordFailure(WRITE_OPERATION, errorType, durationNanos); + recordLatency(ref, WRITE_OPERATION, FAILURE_STATUS, errorType, durationNanos); + incrementCounter(ref, WRITE_OPERATION, FAILURE_STATUS, errorType); + log.debug("Write failed: [{}] {}", errorType, e.getMessage()); + } + } + + private void doRead(String ref) { + Integer id = pickReadableId(); + if (id == null) { + return; + } + String guid = guidFromInt(id); + + long start = System.nanoTime(); + try { + sloService.select(guid, id); + long durationNanos = System.nanoTime() - start; + sloStats.recordSuccess(READ_OPERATION, durationNanos); + recordLatency(ref, READ_OPERATION, SUCCESS_STATUS, NO_ERROR_TYPE, durationNanos); + incrementCounter(ref, READ_OPERATION, SUCCESS_STATUS, NO_ERROR_TYPE); + } catch (Exception e) { + String errorType = extractErrorType(e); + long durationNanos = System.nanoTime() - start; + sloStats.recordFailure(READ_OPERATION, errorType, durationNanos); + recordLatency(ref, READ_OPERATION, FAILURE_STATUS, errorType, durationNanos); + incrementCounter(ref, READ_OPERATION, FAILURE_STATUS, errorType); + log.debug("Read failed: [{}] {}", errorType, e.getMessage()); + } + } + + private void registerReadableId(int id) { + readableIds.add(id); + } + + private Integer pickReadableId() { + synchronized (readableIds) { + if (readableIds.isEmpty()) { + return null; + } + return readableIds.get(ThreadLocalRandom.current().nextInt(readableIds.size())); + } + } + + private void incrementCounter(String ref, String operationType, String status, String errorType) { + Attributes attrs = Attributes.builder() + .put(REF_KEY, ref) + .put(OP_TYPE_KEY, operationType) + .put(STATUS_KEY, status) + .put(ERROR_TYPE_KEY, errorType) + .build(); + operationsCounter.add(1, attrs); + } + + private void recordLatency(String ref, String operationType, String status, String errorType, + long durationNanos) { + Attributes attrs = Attributes.builder() + .put(REF_KEY, ref) + .put(OP_TYPE_KEY, operationType) + .put(STATUS_KEY, status) + .put(ERROR_TYPE_KEY, errorType) + .build(); + durationHistogram.record(durationNanos / 1_000_000_000.0, attrs); + } + + static String extractErrorType(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof YdbStatusable statusable) { + Status status = statusable.getStatus(); + if (status != null && status.getCode() != null) { + return status.getCode().name(); + } + } + current = current.getCause(); + } + return throwable.getClass().getSimpleName(); + } + + static String guidFromInt(int value) { + try { + byte[] intBytes = new byte[4]; + intBytes[0] = (byte) (value >> 24); + intBytes[1] = (byte) (value >> 16); + intBytes[2] = (byte) (value >> 8); + intBytes[3] = (byte) value; + byte[] hash = MessageDigest.getInstance("SHA-1").digest(intBytes); + StringBuilder sb = new StringBuilder(36); + for (int i = 0; i < 16; i++) { + sb.append(String.format("%02x", hash[i])); + if (i == 3 || i == 5 || i == 7 || i == 9) { + sb.append('-'); + } + } + return sb.toString(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + static String randomString() { + ThreadLocalRandom rng = ThreadLocalRandom.current(); + int len = 20 + rng.nextInt(21); + StringBuilder sb = new StringBuilder(len); + for (int i = 0; i < len; i++) { + sb.append((char) (32 + rng.nextInt(95))); + } + return sb.toString(); + } + + private void writeRunSummaryFile(String runId, Instant startedAt, Instant finishedAt) { + resultWriter.writeSummary( + config, + retryProperties, + sloStats.calculate(runId, startedAt, finishedAt, sloStats) + ); + } + + private void waitForPrometheusScrapes(String runId) { + log.info( + "Waiting {}s before shutdown to allow final Prometheus scrapes: runId={}", + 10, + runId + ); + try { + Thread.sleep(TimeUnit.SECONDS.toMillis(10)); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while waiting for final Prometheus scrapes", interruptedException); + } + } + + private static void awaitTermination(String name, ExecutorService executorService, long timeout, TimeUnit unit) { + try { + if (!executorService.awaitTermination(timeout, unit)) { + throw new IllegalStateException(name + " did not terminate in time"); + } + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + throw new RuntimeException(name + " termination interrupted", interruptedException); + } + } + +} diff --git a/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloService.java b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloService.java new file mode 100644 index 00000000..c4618953 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloService.java @@ -0,0 +1,60 @@ +package tech.ydb.slo; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import tech.ydb.retry.YdbTransactional; + +import java.sql.Timestamp; +import java.time.LocalDateTime; + +@Service +public class SloService { + + private static final Logger log = LoggerFactory.getLogger(SloService.class); + private static final String TABLE_NAME = "slo_test_table"; + private static final String SELECT_MAX_ID_SQL = "SELECT MAX(id) FROM " + TABLE_NAME; + private static final int SECOND_UPSERT_ID_OFFSET = 1; + + private final JdbcTemplate jdbcTemplate; + + public SloService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @YdbTransactional + public void upsert(String guid, int id, String payloadStr, double payloadDouble, + LocalDateTime payloadTimestamp) { + jdbcTemplate.update("UPSERT INTO " + TABLE_NAME + " (guid, id, payload_str, payload_double, payload_timestamp) VALUES (?, ?, ?, ?, ?)", + guid, id, payloadStr, payloadDouble, Timestamp.valueOf(payloadTimestamp) + ); + } + + @YdbTransactional + public void upsert2(String guid, int id, String payloadStr, double payloadDouble, + LocalDateTime payloadTimestamp) { + jdbcTemplate.update("UPSERT INTO " + TABLE_NAME + " (guid, id, payload_str, payload_double, payload_timestamp) VALUES (?, ?, ?, ?, ?)", + guid, id, payloadStr, payloadDouble, Timestamp.valueOf(payloadTimestamp) + ); + + jdbcTemplate.update( + "UPSERT INTO " + TABLE_NAME + " (guid, id, payload_str, payload_double, payload_timestamp) VALUES (?, ?, ?, ?, ?)", + guid, id + SECOND_UPSERT_ID_OFFSET, payloadStr, + payloadDouble, Timestamp.valueOf(payloadTimestamp) + ); + } + + @YdbTransactional(readOnly = true) + public String select(String guid, int id) { + return jdbcTemplate.queryForObject("SELECT payload_str FROM " + TABLE_NAME + " WHERE guid = ? AND id = ?", + String.class, guid, id + ); + } + + @YdbTransactional(readOnly = true) + public int selectMaxId() { + Integer result = jdbcTemplate.queryForObject(SELECT_MAX_ID_SQL, Integer.class); + return result != null ? result : 0; + } +} diff --git a/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloStats.java b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloStats.java new file mode 100644 index 00000000..5285e221 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloStats.java @@ -0,0 +1,155 @@ +package tech.ydb.slo; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.LongAdder; + +public class SloStats { + + private static final String READ_OPERATION = "read"; + private static final String EMPTY_PERCENTILE = "n/a"; + private static final String FAILURE_RATE_FORMAT = "%.4f"; + private static final String LATENCY_FORMAT = "%.3f"; + private static final double PERCENTILE_50 = 0.50; + private static final double PERCENTILE_95 = 0.95; + private static final double PERCENTILE_99 = 0.99; + private static final double NANOS_IN_MILLISECOND = 1_000_000.0; + private static final double PERCENT_FACTOR = 100.0; + + private final AtomicLong readSuccess = new AtomicLong(); + private final AtomicLong readFailure = new AtomicLong(); + private final AtomicLong writeSuccess = new AtomicLong(); + private final AtomicLong writeFailure = new AtomicLong(); + private final List overallLatenciesNanos = Collections.synchronizedList(new ArrayList<>()); + private final List readLatenciesNanos = Collections.synchronizedList(new ArrayList<>()); + private final List writeLatenciesNanos = Collections.synchronizedList(new ArrayList<>()); + private final ConcurrentHashMap errorCounts = new ConcurrentHashMap<>(); + + public void recordSuccess(String operationType, long durationNanos) { + recordLatency(operationType, durationNanos); + if (READ_OPERATION.equals(operationType)) { + readSuccess.incrementAndGet(); + return; + } + writeSuccess.incrementAndGet(); + } + + public void recordFailure(String operationType, String errorType, long durationNanos) { + recordLatency(operationType, durationNanos); + errorCounts.computeIfAbsent(errorType, ignored -> new LongAdder()).increment(); + if (READ_OPERATION.equals(operationType)) { + readFailure.incrementAndGet(); + return; + } + writeFailure.incrementAndGet(); + } + + public long getReadSuccess() { + return readSuccess.get(); + } + + public long getReadFailure() { + return readFailure.get(); + } + + public long getWriteSuccess() { + return writeSuccess.get(); + } + + public long getWriteFailure() { + return writeFailure.get(); + } + + public List overallLatenciesSnapshot() { + return snapshotLatencies(overallLatenciesNanos); + } + + public List readLatenciesSnapshot() { + return snapshotLatencies(readLatenciesNanos); + } + + public List writeLatenciesSnapshot() { + return snapshotLatencies(writeLatenciesNanos); + } + + public Map errorCountsSnapshot() { + return errorCounts.entrySet().stream() + .sorted(Map.Entry.comparingByValue( + Comparator.comparingLong(LongAdder::sum) + ).reversed()) + .collect(LinkedHashMap::new, + (map, entry) -> map.put(entry.getKey(), entry.getValue().sum()), + LinkedHashMap::putAll); + } + + private void recordLatency(String operationType, long durationNanos) { + overallLatenciesNanos.add(durationNanos); + if (READ_OPERATION.equals(operationType)) { + readLatenciesNanos.add(durationNanos); + return; + } + writeLatenciesNanos.add(durationNanos); + } + + private static List snapshotLatencies(List latenciesNanos) { + synchronized (latenciesNanos) { + return new ArrayList<>(latenciesNanos); + } + } + + public SloResultWriter.RunSummary calculate(String runId, + Instant startedAt, + Instant finishedAt, + SloStats runStats) { + long readSuccess = runStats.getReadSuccess(); + long readFailure = runStats.getReadFailure(); + long writeSuccess = runStats.getWriteSuccess(); + long writeFailure = runStats.getWriteFailure(); + long totalSuccess = readSuccess + writeSuccess; + long totalFailure = readFailure + writeFailure; + long totalOperations = totalSuccess + totalFailure; + double failureRatePercent = totalOperations == 0 ? 0.0 : (double) totalFailure * PERCENT_FACTOR / totalOperations; + + return new SloResultWriter.RunSummary( + runId, + startedAt, + finishedAt, + totalOperations, + totalSuccess, + totalFailure, + String.format(Locale.ROOT, FAILURE_RATE_FORMAT, failureRatePercent), + readSuccess, + readFailure, + writeSuccess, + writeFailure, + formatPercentileMillis(runStats.overallLatenciesSnapshot(), PERCENTILE_50), + formatPercentileMillis(runStats.overallLatenciesSnapshot(), PERCENTILE_95), + formatPercentileMillis(runStats.overallLatenciesSnapshot(), PERCENTILE_99), + formatPercentileMillis(runStats.readLatenciesSnapshot(), PERCENTILE_50), + formatPercentileMillis(runStats.readLatenciesSnapshot(), PERCENTILE_95), + formatPercentileMillis(runStats.readLatenciesSnapshot(), PERCENTILE_99), + formatPercentileMillis(runStats.writeLatenciesSnapshot(), PERCENTILE_50), + formatPercentileMillis(runStats.writeLatenciesSnapshot(), PERCENTILE_95), + formatPercentileMillis(runStats.writeLatenciesSnapshot(), PERCENTILE_99), + runStats.errorCountsSnapshot() + ); + } + + private static String formatPercentileMillis(List latenciesNanos, double percentile) { + if (latenciesNanos.isEmpty()) { + return EMPTY_PERCENTILE; + } + latenciesNanos.sort(Long::compareTo); + int index = Math.min(latenciesNanos.size() - 1, (int) Math.ceil(percentile * latenciesNanos.size()) - 1); + double millis = latenciesNanos.get(index) / NANOS_IN_MILLISECOND; + return String.format(Locale.ROOT, LATENCY_FORMAT, millis); + } +} diff --git a/spring-ydb/spring-ydb-retry/slo/src/main/resources/application.properties b/spring-ydb/spring-ydb-retry/slo/src/main/resources/application.properties new file mode 100644 index 00000000..27f619bc --- /dev/null +++ b/spring-ydb/spring-ydb-retry/slo/src/main/resources/application.properties @@ -0,0 +1,24 @@ +server.port=${SERVER_PORT:8080} + +spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:ydb:grpc://localhost:2136/Root/testdb} +spring.datasource.driver-class-name=tech.ydb.jdbc.YdbDriver +spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration + +ydb.transaction.retry.enabled=${YDB_TRANSACTION_RETRY_ENABLED:true} +ydb.transaction.retry.max-retries=${YDB_TRANSACTION_RETRY_MAX_RETRIES:10} +ydb.transaction.retry.idempotent=${YDB_TRANSACTION_RETRY_IDEMPOTENT:true} + +slo.read-rps=${SLO_READ_RPS:100} +slo.write-rps=${SLO_WRITE_RPS:100} +slo.initial-data-count=${SLO_INITIAL_DATA:1000} +slo.run-time-seconds=${SLO_TIME:600} +slo.ref=${REF:unknown} +slo.run-id=${SLO_RUN_ID:} +slo.results-dir=${SLO_RESULTS_DIR:results} + +management.endpoints.web.exposure.include=prometheus,health,info +management.metrics.export.prometheus.enabled=true +management.health.db.enabled=false + +logging.level.tech.ydb=INFO +logging.level.tech.ydb.slo=INFO diff --git a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/BackoffSleeper.java b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/BackoffSleeper.java new file mode 100644 index 00000000..2650548b --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/BackoffSleeper.java @@ -0,0 +1,6 @@ +package tech.ydb.retry; + +@FunctionalInterface +public interface BackoffSleeper { + void sleep(long delayMs) throws InterruptedException; +} diff --git a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbDelayCalculator.java b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbDelayCalculator.java new file mode 100644 index 00000000..3d0640c3 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbDelayCalculator.java @@ -0,0 +1,36 @@ +package tech.ydb.retry; + +import org.springframework.lang.Nullable; +import tech.ydb.core.StatusCode; + +public class YdbDelayCalculator { + public static long calculateDelay(@Nullable StatusCode statusCode, YdbRetryPolicyConfig retryConfig, int attempt) { + if (statusCode == null) { + return 0; + } + + return switch (statusCode) { + case BAD_SESSION, SESSION_BUSY -> 0; + case UNDETERMINED, ABORTED, CLIENT_CANCELLED, CLIENT_INTERNAL_ERROR -> + delayWithFullJitter(retryConfig.getFastBackoffBaseMs(), retryConfig.getFastCapBackoffMs(), + retryConfig.getFastPow(), attempt, retryConfig); + case UNAVAILABLE, TRANSPORT_UNAVAILABLE -> + delayWithEqualJitter(retryConfig.getFastBackoffBaseMs(), retryConfig.getFastCapBackoffMs(), + retryConfig.getFastPow(), attempt, retryConfig); + case OVERLOADED, CLIENT_RESOURCE_EXHAUSTED -> + delayWithEqualJitter(retryConfig.getSlowBackoffBaseMs(), retryConfig.getSlowCapBackoffMs(), + retryConfig.getSlowPow(), attempt, retryConfig); + default -> 0; + }; + } + + private static long delayWithFullJitter(int baseMs, int capMs, int pow, int attempt, YdbRetryPolicyConfig retryConfig) { + long currentDelay = Math.min(baseMs * ((1L << Math.min(pow, attempt)) - 1), capMs); + return retryConfig.getJitter(currentDelay); + } + + private static long delayWithEqualJitter(int baseMs, int capMs, int pow, int attempt, YdbRetryPolicyConfig retryConfig) { + long tmp = (long) baseMs * ((1L << Math.min(pow, attempt)) - 1) / 2; + return Math.min(tmp + retryConfig.getJitter(tmp), capMs); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbRetryPolicy.java b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbRetryPolicy.java new file mode 100644 index 00000000..4ff793c4 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbRetryPolicy.java @@ -0,0 +1,10 @@ +package tech.ydb.retry; + +import org.springframework.lang.Nullable; +import tech.ydb.core.StatusCode; + +public final class YdbRetryPolicy { + public static boolean shouldRetry(@Nullable StatusCode statusCode, boolean isIdempotent) { + return statusCode != null && statusCode.isRetryable(isIdempotent); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbRetryPolicyConfig.java b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbRetryPolicyConfig.java new file mode 100644 index 00000000..947319bf --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbRetryPolicyConfig.java @@ -0,0 +1,142 @@ +package tech.ydb.retry; + +import java.util.concurrent.ThreadLocalRandom; +import org.springframework.lang.Nullable; + +public final class YdbRetryPolicyConfig { + public static final boolean DEFAULT_ENABLED = true; + public static final int DEFAULT_MAX_RETRIES = 10; + public static final int DEFAULT_SLOW_BACKOFF_BASE_MS = 50; + public static final int DEFAULT_FAST_BACKOFF_BASE_MS = 5; + public static final int DEFAULT_SLOW_CAP_BACKOFF_MS = 5_000; + public static final int DEFAULT_FAST_CAP_BACKOFF_MS = 500; + public static final boolean DEFAULT_IDEMPOTENT = false; + + private final boolean enabled; + private final int maxRetries; + private final int slowBackoffBaseMs; + private final int fastBackoffBaseMs; + private final int slowCapBackoffMs; + private final int fastCapBackoffMs; + private final int slowPow; + private final int fastPow; + private final boolean idempotent; + + public YdbRetryPolicyConfig() { + this( + DEFAULT_ENABLED, + DEFAULT_MAX_RETRIES, + DEFAULT_SLOW_BACKOFF_BASE_MS, + DEFAULT_FAST_BACKOFF_BASE_MS, + DEFAULT_SLOW_CAP_BACKOFF_MS, + DEFAULT_FAST_CAP_BACKOFF_MS, + DEFAULT_IDEMPOTENT + ); + } + + public YdbRetryPolicyConfig(boolean enabled, int maxRetries, int slowBackoffBaseMs, int fastBackoffBaseMs, + int slowCapBackoffMs, int fastCapBackoffMs) { + this(enabled, maxRetries, slowBackoffBaseMs, fastBackoffBaseMs, slowCapBackoffMs, fastCapBackoffMs, false); + } + + public YdbRetryPolicyConfig(boolean enabled, int maxRetries, int slowBackoffBaseMs, int fastBackoffBaseMs, + int slowCapBackoffMs, int fastCapBackoffMs, boolean idempotent) { + if (maxRetries < 1) { + throw new IllegalArgumentException("maxRetries must be >= 1"); + } + if (slowBackoffBaseMs < 0 || fastBackoffBaseMs < 0 || slowCapBackoffMs < 0 || fastCapBackoffMs < 0) { + throw new IllegalArgumentException("backoff values must be >= 0"); + } + this.enabled = enabled; + this.slowBackoffBaseMs = slowBackoffBaseMs; + this.fastBackoffBaseMs = fastBackoffBaseMs; + this.slowCapBackoffMs = slowCapBackoffMs; + this.fastCapBackoffMs = fastCapBackoffMs; + this.maxRetries = maxRetries; + this.slowPow = powerForCap(this.slowCapBackoffMs); + this.fastPow = powerForCap(this.fastCapBackoffMs); + this.idempotent = idempotent; + } + + public long getJitter(long bound) { + if (bound <= 0) { + return 0; + } + return ThreadLocalRandom.current().nextLong(bound); + } + + public boolean isEnabled() { + return enabled; + } + + public int getMaxRetries() { + return maxRetries; + } + + public int getSlowBackoffBaseMs() { + return slowBackoffBaseMs; + } + + public int getFastBackoffBaseMs() { + return fastBackoffBaseMs; + } + + public int getSlowCapBackoffMs() { + return slowCapBackoffMs; + } + + public int getFastCapBackoffMs() { + return fastCapBackoffMs; + } + + public int getSlowPow() { + return slowPow; + } + + public int getFastPow() { + return fastPow; + } + + public boolean isIdempotent() { + return idempotent; + } + + public YdbRetryPolicyConfig merge(@Nullable YdbTransactional transactionPolicy) { + if (transactionPolicy == null) { + return this; + } + return new YdbRetryPolicyConfig( + enabled && transactionPolicy.enabled(), + checkCandidate("maxRetries", transactionPolicy.maxRetries(), maxRetries), + checkCandidate("slowBackoffBaseMs", transactionPolicy.slowBackoffBaseMs(), slowBackoffBaseMs), + checkCandidate("fastBackoffBaseMs", transactionPolicy.fastBackoffBaseMs(), fastBackoffBaseMs), + checkCandidate("slowCapBackoffMs", transactionPolicy.slowCapBackoffMs(), slowCapBackoffMs), + checkCandidate("fastCapBackoffMs", transactionPolicy.fastCapBackoffMs(), fastCapBackoffMs), + checkIdempotent(transactionPolicy.idempotent(), idempotent) + ); + } + + private static int checkCandidate(String name, int candidate, int fallback) throws IllegalArgumentException { + if (candidate < -1) { + throw new IllegalArgumentException(String.format("%s is invalid", name)); + } + return candidate == -1 ? fallback : candidate; + } + + private static boolean checkIdempotent(int candidate, boolean fallback) { + if (candidate == -1) { + return fallback; + } + if (candidate < -1 || candidate > 1) { + throw new IllegalArgumentException("idempotent must be -1, 0, or 1"); + } + return candidate == 1; + } + + private static int powerForCap(int capMs) { + if (capMs <= 1) { + return 1; + } + return Math.max(1, (int) (Math.log(capMs) / Math.log(2))); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbRetryProperties.java b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbRetryProperties.java new file mode 100644 index 00000000..800f7dc1 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbRetryProperties.java @@ -0,0 +1,83 @@ +package tech.ydb.retry; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "ydb.transaction.retry") +public class YdbRetryProperties { + + private boolean enabled = YdbRetryPolicyConfig.DEFAULT_ENABLED; + private int maxRetries = YdbRetryPolicyConfig.DEFAULT_MAX_RETRIES; + private int slowBackoffBaseMs = YdbRetryPolicyConfig.DEFAULT_SLOW_BACKOFF_BASE_MS; + private int fastBackoffBaseMs = YdbRetryPolicyConfig.DEFAULT_FAST_BACKOFF_BASE_MS; + private int slowCapBackoffMs = YdbRetryPolicyConfig.DEFAULT_SLOW_CAP_BACKOFF_MS; + private int fastCapBackoffMs = YdbRetryPolicyConfig.DEFAULT_FAST_CAP_BACKOFF_MS; + private boolean idempotent = YdbRetryPolicyConfig.DEFAULT_IDEMPOTENT; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public int getMaxRetries() { + return maxRetries; + } + + public void setMaxRetries(int maxRetries) { + this.maxRetries = maxRetries; + } + + public int getSlowBackoffBaseMs() { + return slowBackoffBaseMs; + } + + public void setSlowBackoffBaseMs(int slowBackoffBaseMs) { + this.slowBackoffBaseMs = slowBackoffBaseMs; + } + + public int getFastBackoffBaseMs() { + return fastBackoffBaseMs; + } + + public void setFastBackoffBaseMs(int fastBackoffBaseMs) { + this.fastBackoffBaseMs = fastBackoffBaseMs; + } + + public int getSlowCapBackoffMs() { + return slowCapBackoffMs; + } + + public void setSlowCapBackoffMs(int slowCapBackoffMs) { + this.slowCapBackoffMs = slowCapBackoffMs; + } + + public int getFastCapBackoffMs() { + return fastCapBackoffMs; + } + + public void setFastCapBackoffMs(int fastCapBackoffMs) { + this.fastCapBackoffMs = fastCapBackoffMs; + } + + public boolean isIdempotent() { + return idempotent; + } + + public void setIdempotent(boolean idempotent) { + this.idempotent = idempotent; + } + + public YdbRetryPolicyConfig toConfig() { + return new YdbRetryPolicyConfig( + enabled, + maxRetries, + slowBackoffBaseMs, + fastBackoffBaseMs, + slowCapBackoffMs, + fastCapBackoffMs, + idempotent + ); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionAutoConfiguration.java b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionAutoConfiguration.java new file mode 100644 index 00000000..a972b93c --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionAutoConfiguration.java @@ -0,0 +1,28 @@ +package tech.ydb.retry; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.interceptor.TransactionInterceptor; + +@Configuration +@AutoConfigureBefore(TransactionAutoConfiguration.class) +@ConditionalOnClass(TransactionInterceptor.class) +@EnableConfigurationProperties(YdbRetryProperties.class) +public class YdbTransactionAutoConfiguration { + + private static final Logger log = LoggerFactory.getLogger(YdbTransactionAutoConfiguration.class); + + @Bean + @ConditionalOnMissingBean + public static YdbTransactionInterceptorReplacer ydbBeanDefinitionRegistryPostProcessor() { + log.debug("creating YdbBeanDefinitionRegistryPostProcessor bean"); + return new YdbTransactionInterceptorReplacer(); + } +} \ No newline at end of file diff --git a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptor.java b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptor.java new file mode 100644 index 00000000..02cd789c --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptor.java @@ -0,0 +1,151 @@ +package tech.ydb.retry; + +import java.lang.reflect.Method; +import org.aopalliance.intercept.MethodInvocation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.aop.support.AopUtils; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.lang.Nullable; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.interceptor.TransactionAttribute; +import org.springframework.transaction.interceptor.TransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import tech.ydb.core.StatusCode; +import tech.ydb.jdbc.exception.YdbStatusable; + +public class YdbTransactionInterceptor extends TransactionInterceptor { + + private static final Logger log = LoggerFactory.getLogger(YdbTransactionInterceptor.class); + private final YdbRetryPolicyConfig retryConfig; + private final BackoffSleeper backoffSleeper; + + public YdbTransactionInterceptor() { + this(new YdbRetryPolicyConfig(), Thread::sleep); + } + + YdbTransactionInterceptor(YdbRetryPolicyConfig retryConfig, + BackoffSleeper backoffSleeper) { + this.retryConfig = retryConfig; + this.backoffSleeper = backoffSleeper; + } + + @Override + @Nullable + public Object invoke(final MethodInvocation invocation) throws Throwable { + Class targetClass = invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null; + + TransactionAttributeSource tas = getTransactionAttributeSource(); + final TransactionAttribute txAttr = (tas != null ? tas.getTransactionAttribute(invocation.getMethod(), targetClass) : null); + if (txAttr == null) { + return this.invokeWithinTransaction(invocation.getMethod(), targetClass, createCallback(invocation)); + } + + if (isParticipatingInExistingTransaction(txAttr)) { + log.debug( + "YDB retry is disabled for method {} because it participates in an existing transaction", + invocation.getMethod().toGenericString() + ); + return this.invokeWithinTransaction(invocation.getMethod(), targetClass, createCallback(invocation)); + } + + YdbTransactional ydbTransactional = resolveYdbTransactionAnnotation(invocation.getMethod(), targetClass); + YdbRetryPolicyConfig retryConfig = this.retryConfig.merge(ydbTransactional); + + if (!retryConfig.isEnabled()) { + log.debug("YDB retry is disabled for method {}", invocation.getMethod().toGenericString()); + return this.invokeWithinTransaction(invocation.getMethod(), targetClass, createCallback(invocation)); + } + + return invokeWithinTransactionWithRetryContext(invocation, targetClass, retryConfig); + } + + @Nullable + private Object invokeWithinTransactionWithRetryContext(final MethodInvocation invocation, + @Nullable Class targetClass, + YdbRetryPolicyConfig retryConfig) throws Throwable { + for (int attempt = 1; attempt <= retryConfig.getMaxRetries() + 1; attempt++) { + try { + return this.invokeWithinTransaction(invocation.getMethod(), targetClass, createCallback(invocation)); + } catch (Throwable ex) { + if (ex instanceof Error) { + throw ex; + } + StatusCode statusCode = extractStatusCode(ex); + if (!YdbRetryPolicy.shouldRetry(statusCode, retryConfig.isIdempotent())) { + throw ex; + } + if (attempt == retryConfig.getMaxRetries() + 1) { + throw ex; + } + long delay = YdbDelayCalculator.calculateDelay(statusCode, retryConfig, attempt - 1); + sleep(delay, ex); + } + } + throw new IllegalStateException("retry loop finished unexpectedly"); + } + + private void sleep(long delay, Throwable originalException) throws Throwable { + try { + backoffSleeper.sleep(delay); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + interruptedException.addSuppressed(originalException); + throw interruptedException; + } + } + + private boolean isParticipatingInExistingTransaction(TransactionAttribute txAttr) { + if (!TransactionSynchronizationManager.isActualTransactionActive()) { + return false; + } + int propagationBehavior = txAttr.getPropagationBehavior(); + + return propagationBehavior != TransactionDefinition.PROPAGATION_REQUIRES_NEW + && propagationBehavior != TransactionDefinition.PROPAGATION_NOT_SUPPORTED + && propagationBehavior != TransactionDefinition.PROPAGATION_NEVER; + } + + @Nullable + private YdbTransactional resolveYdbTransactionAnnotation(Method method, @Nullable Class targetClass) { + Method specificMethod = targetClass != null ? AopUtils.getMostSpecificMethod(method, targetClass) : method; + YdbTransactional methodLevel = AnnotatedElementUtils.findMergedAnnotation(specificMethod, YdbTransactional.class); + if (methodLevel != null) { + return methodLevel; + } + if (targetClass != null) { + return AnnotatedElementUtils.findMergedAnnotation(targetClass, YdbTransactional.class); + } + return null; + } + + @Nullable + private StatusCode extractStatusCode(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof YdbStatusable statusable && statusable.getStatus() != null) { + return statusable.getStatus().getCode(); + } + current = current.getCause(); + } + return null; + } + + private InvocationCallback createCallback(MethodInvocation invocation) { + return new InvocationCallback() { + @Nullable + public Object proceedWithInvocation() throws Throwable { + return invocation.proceed(); + } + + public Object getTarget() { + return invocation.getThis(); + } + + public Object[] getArguments() { + return invocation.getArguments(); + } + }; + } +} diff --git a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorFactory.java b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorFactory.java new file mode 100644 index 00000000..990d3b97 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorFactory.java @@ -0,0 +1,72 @@ +package tech.ydb.retry; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.beans.factory.FactoryBean; +import org.springframework.lang.Nullable; +import org.springframework.transaction.TransactionManager; +import org.springframework.transaction.annotation.TransactionManagementConfigurer; +import org.springframework.transaction.interceptor.TransactionAttributeSource; + +public class YdbTransactionInterceptorFactory implements FactoryBean, BeanFactoryAware { + + private YdbRetryProperties retryProperties; + private TransactionAttributeSource transactionAttributeSource; + + @Nullable + private BeanFactory beanFactory; + + public void setRetryProperties(YdbRetryProperties retryProperties) { + this.retryProperties = retryProperties; + } + + public void setTransactionAttributeSource(TransactionAttributeSource transactionAttributeSource) { + this.transactionAttributeSource = transactionAttributeSource; + } + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + this.beanFactory = beanFactory; + } + + @Override + public YdbTransactionInterceptor getObject() { + YdbTransactionInterceptor interceptor = new YdbTransactionInterceptor( + retryProperties.toConfig(), + Thread::sleep + ); + interceptor.setTransactionAttributeSource(transactionAttributeSource); + if (beanFactory != null) { + interceptor.setBeanFactory(beanFactory); + } + + TransactionManager defaultTransactionManager = resolveTransactionManager(); + if (defaultTransactionManager != null) { + interceptor.setTransactionManager(defaultTransactionManager); + } + + return interceptor; + } + + @Nullable + private TransactionManager resolveTransactionManager() { + if (beanFactory == null) { + return null; + } + + TransactionManagementConfigurer configurer = beanFactory + .getBeanProvider(TransactionManagementConfigurer.class) + .getIfAvailable(); + if (configurer == null) { + return null; + } + + return configurer.annotationDrivenTransactionManager(); + } + + @Override + public Class getObjectType() { + return YdbTransactionInterceptor.class; + } +} diff --git a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorReplacer.java b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorReplacer.java new file mode 100644 index 00000000..316cdd18 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorReplacer.java @@ -0,0 +1,83 @@ +package tech.ydb.retry; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.support.AbstractBeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; +import org.springframework.core.Ordered; + +public class YdbTransactionInterceptorReplacer implements BeanDefinitionRegistryPostProcessor, Ordered { + + private static final Logger log = LoggerFactory.getLogger(YdbTransactionInterceptorReplacer.class); + + private static final String TRANSACTION_INTERCEPTOR_BEAN_NAME = "transactionInterceptor"; + + @Override + public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException { + if (!registry.containsBeanDefinition(TRANSACTION_INTERCEPTOR_BEAN_NAME)) { + log.debug("BeanDefinition '{}' not found", TRANSACTION_INTERCEPTOR_BEAN_NAME); + return; + } + + BeanDefinition existingBd = registry.getBeanDefinition(TRANSACTION_INTERCEPTOR_BEAN_NAME); + + if (YdbTransactionInterceptorFactory.class.getName().equals(existingBd.getBeanClassName())) { + log.debug("BeanDefinition '{}' is already YdbTransactionInterceptorFactory", TRANSACTION_INTERCEPTOR_BEAN_NAME); + return; + } + + AbstractBeanDefinition newBd = buildYdbInterceptorBeanDefinition(existingBd); + + registry.removeBeanDefinition(TRANSACTION_INTERCEPTOR_BEAN_NAME); + registry.registerBeanDefinition(TRANSACTION_INTERCEPTOR_BEAN_NAME, newBd); + + log.info("registered YdbTransactionInterceptorFactory as bean '{}'", TRANSACTION_INTERCEPTOR_BEAN_NAME); + } + + private AbstractBeanDefinition buildYdbInterceptorBeanDefinition(BeanDefinition existingBd) { + AbstractBeanDefinition newBd = BeanDefinitionBuilder + .genericBeanDefinition(YdbTransactionInterceptorFactory.class) + .setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE) + .getBeanDefinition(); + + copyBeanDefinitionMetadata(existingBd, newBd); + return newBd; + } + + private void copyBeanDefinitionMetadata(BeanDefinition source, AbstractBeanDefinition target) { + target.setParentName(source.getParentName()); + target.setRole(source.getRole()); + target.setScope(source.getScope()); + target.setLazyInit(source.isLazyInit()); + target.setPrimary(source.isPrimary()); + target.setFallback(source.isFallback()); + target.setDependsOn(source.getDependsOn()); + target.setDescription(source.getDescription()); + target.setSource(source.getSource()); + + if (source instanceof AbstractBeanDefinition abstractSource) { + target.setAutowireCandidate(abstractSource.isAutowireCandidate()); + target.setDefaultCandidate(abstractSource.isDefaultCandidate()); + target.setSynthetic(abstractSource.isSynthetic()); + target.setResource(abstractSource.getResource()); + target.setResourceDescription(abstractSource.getResourceDescription()); + if (abstractSource.getOriginatingBeanDefinition() != null) { + target.setOriginatingBeanDefinition(abstractSource.getOriginatingBeanDefinition()); + } + target.copyQualifiersFrom(abstractSource); + + for (String attributeName : abstractSource.attributeNames()) { + target.setAttribute(attributeName, abstractSource.getAttribute(attributeName)); + } + } + } + + @Override + public int getOrder() { + return LOWEST_PRECEDENCE; + } +} diff --git a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactional.java b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactional.java new file mode 100644 index 00000000..b3047be9 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactional.java @@ -0,0 +1,71 @@ +package tech.ydb.retry; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.core.annotation.AliasFor; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@Transactional +public @interface YdbTransactional { + + @AliasFor(annotation = Transactional.class, attribute = "value") + String value() default ""; + + @AliasFor(annotation = Transactional.class, attribute = "transactionManager") + String transactionManager() default ""; + + @AliasFor(annotation = Transactional.class, attribute = "label") + String[] label() default {}; + + @AliasFor(annotation = Transactional.class, attribute = "propagation") + Propagation propagation() default Propagation.REQUIRED; + + @AliasFor(annotation = Transactional.class, attribute = "isolation") + Isolation isolation() default Isolation.DEFAULT; + + @AliasFor(annotation = Transactional.class, attribute = "timeout") + int timeout() default TransactionDefinition.TIMEOUT_DEFAULT; + + @AliasFor(annotation = Transactional.class, attribute = "timeoutString") + String timeoutString() default ""; + + @AliasFor(annotation = Transactional.class, attribute = "readOnly") + boolean readOnly() default false; + + @AliasFor(annotation = Transactional.class, attribute = "rollbackFor") + Class[] rollbackFor() default {}; + + @AliasFor(annotation = Transactional.class, attribute = "rollbackForClassName") + String[] rollbackForClassName() default {}; + + @AliasFor(annotation = Transactional.class, attribute = "noRollbackFor") + Class[] noRollbackFor() default {}; + + @AliasFor(annotation = Transactional.class, attribute = "noRollbackForClassName") + String[] noRollbackForClassName() default {}; + + boolean enabled() default true; + + int maxRetries() default -1; + + int slowBackoffBaseMs() default -1; + + int fastBackoffBaseMs() default -1; + + int slowCapBackoffMs() default -1; + + int fastCapBackoffMs() default -1; + + int idempotent() default -1; +} diff --git a/spring-ydb/spring-ydb-retry/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-ydb/spring-ydb-retry/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..e20c6892 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +tech.ydb.retry.YdbTransactionAutoConfiguration diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/InterceptorTestSupport.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/InterceptorTestSupport.java new file mode 100644 index 00000000..3c842801 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/InterceptorTestSupport.java @@ -0,0 +1,196 @@ +package tech.ydb.retry; + +import java.lang.reflect.Method; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.concurrent.atomic.AtomicInteger; +import org.aopalliance.intercept.MethodInvocation; +import org.junit.jupiter.api.AfterEach; +import org.mockito.Mockito; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import tech.ydb.core.Status; +import tech.ydb.core.StatusCode; +import tech.ydb.jdbc.exception.YdbStatusable; + +abstract class InterceptorTestSupport { + + @AfterEach + void cleanupTransactionContext() { + TransactionSynchronizationManager.clear(); + } + + static TestableInterceptor interceptorWithConfig(boolean enabled, int maxRetries, int slowBase, int fastBase, + int slowCap, int fastCap, boolean isIdempotent) { + return interceptorWithSleeper(enabled, maxRetries, slowBase, fastBase, slowCap, fastCap, isIdempotent, delay -> { + }); + } + + static TestableInterceptor interceptorWithSleeper(boolean enabled, int maxRetries, int slowBase, int fastBase, + int slowCap, int fastCap, boolean isIdempotent, + BackoffSleeper sleeper) { + TestableInterceptor interceptor = new TestableInterceptor( + new YdbRetryPolicyConfig(enabled, maxRetries, slowBase, fastBase, slowCap, fastCap, isIdempotent), + sleeper + ); + interceptor.setTransactionAttributeSource(new AnnotationTransactionAttributeSource()); + return interceptor; + } + + static MethodInvocation invocationFor(String methodName) { + MethodInvocation invocation = Mockito.mock(MethodInvocation.class); + Method method = methodOf(methodName); + Object target = targetFor(methodName); + Mockito.when(invocation.getMethod()).thenReturn(method); + Mockito.when(invocation.getThis()).thenReturn(target); + Mockito.when(invocation.getArguments()).thenReturn(new Object[0]); + return invocation; + } + + private static Object targetFor(String methodName) { + if (methodName.startsWith("ydb") || methodName.startsWith("default")) { + return new YdbTransactionalTestService(); + } + return new TransactionalTestService(); + } + + static Method methodOf(String methodName) { + try { + if (methodName.startsWith("ydb") || methodName.startsWith("default")) { + return YdbTransactionalTestService.class.getMethod(methodName); + } + return TransactionalTestService.class.getMethod(methodName); + } catch (NoSuchMethodException e) { + throw new IllegalStateException(e); + } + } + + static final class TestableInterceptor extends YdbTransactionInterceptor { + private final Deque outcomes = new ArrayDeque<>(); + private final AtomicInteger attempts = new AtomicInteger(); + + TestableInterceptor(YdbRetryPolicyConfig retryConfig, + BackoffSleeper backoffSleeper) { + super(retryConfig, backoffSleeper); + } + + void enqueueOutcome(Object... results) { + for (Object result : results) { + outcomes.addLast(result); + } + } + + int allInvocations() { + return attempts.get(); + } + + int retries() { + return Math.max(0, attempts.get() - 1); + } + + @Override + protected Object invokeWithinTransaction(Method method, Class targetClass, InvocationCallback invocation) + throws Throwable { + attempts.incrementAndGet(); + Object result = outcomes.removeFirst(); + if (result instanceof Throwable throwable) { + throw throwable; + } + return result; + } + } + + static class TransactionalTestService { + @Transactional + public String regularTx() { + return "ok"; + } + } + + static class YdbTransactionalTestService { + @YdbTransactional(maxRetries = 2) + public String ydbCustomRetry() { + return "ok"; + } + + @YdbTransactional(maxRetries = 5) + public String ydbRequiredRetry() { + return "ok"; + } + + @YdbTransactional(maxRetries = 2, propagation = Propagation.REQUIRES_NEW) + public String ydbRequiresNewRetry() { + return "ok"; + } + + @YdbTransactional(maxRetries = 3, propagation = Propagation.NESTED) + public String ydbNestedRetry() { + return "ok"; + } + + @YdbTransactional(maxRetries = 3, propagation = Propagation.NOT_SUPPORTED) + public String ydbNotSupportedRetry() { + return "ok"; + } + + @YdbTransactional + public String defaultRetry() { + return "ok"; + } + + @YdbTransactional(enabled = false) + public String ydbRetryDisabled() { + return "ok"; + } + + @YdbTransactional(enabled = true) + public String ydbRetryEnabled() { + return "ok"; + } + + @YdbTransactional("customTransactionManager") + public String ydbValueAliasManager() { + return "ok"; + } + + @YdbTransactional(timeoutString = "15") + public String ydbTimeoutString() { + return "ok"; + } + + @YdbTransactional(maxRetries = 100, slowBackoffBaseMs = 200, fastBackoffBaseMs = 10, slowCapBackoffMs = 10000, fastCapBackoffMs = 12) + public String ydbNewTransactionSettings() { + return "ok"; + } + + @YdbTransactional(maxRetries = -2) + public String ydbNegativeMaxRetries() { + return "ok"; + } + + @YdbTransactional(maxRetries = 5, idempotent = 1) + public String ydbIdempotentRetry() { + return "ok"; + } + + @YdbTransactional(maxRetries = 3, idempotent = 0) + public String ydbNonIdempotentRetry() { + return "ok"; + } + } + + static final class ConfigurableStatusException extends RuntimeException implements YdbStatusable { + private final StatusCode statusCode; + + ConfigurableStatusException(StatusCode statusCode) { + this.statusCode = statusCode; + } + + @Override + public Status getStatus() { + return Status.of(statusCode); + } + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/TransactionPropagationRetryTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/TransactionPropagationRetryTest.java new file mode 100644 index 00000000..39cd0a6d --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/TransactionPropagationRetryTest.java @@ -0,0 +1,66 @@ +package tech.ydb.retry; + +import org.junit.jupiter.api.Test; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static tech.ydb.core.StatusCode.ABORTED; +import static tech.ydb.core.StatusCode.BAD_SESSION; + +class TransactionPropagationRetryTest extends InterceptorTestSupport { + + @Test + void shouldDisableRetryWhenParticipatingInOuterTransaction() { + TransactionSynchronizationManager.setActualTransactionActive(true); + + TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0, false); + interceptor.enqueueOutcome(new IllegalStateException("no retry expected")); + + assertThrows( + IllegalStateException.class, + () -> interceptor.invoke(invocationFor("ydbRequiredRetry")) + ); + assertEquals(1, interceptor.allInvocations()); + } + + @Test + void shouldRetryWithRequiresNewInsideOuterTransaction() throws Throwable { + TransactionSynchronizationManager.setActualTransactionActive(true); + + TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0, false); + interceptor.enqueueOutcome(new ConfigurableStatusException(BAD_SESSION), "ok"); + + Object result = interceptor.invoke(invocationFor("ydbRequiresNewRetry")); + + assertEquals("ok", result); + assertEquals(2, interceptor.allInvocations()); + } + + @Test + void shouldDisableRetryWithNestedPropagationInsideOuterTransaction() { + TransactionSynchronizationManager.setActualTransactionActive(true); + + TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0, false); + interceptor.enqueueOutcome(new ConfigurableStatusException(ABORTED)); + + assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("ydbNestedRetry")) + ); + assertEquals(1, interceptor.allInvocations()); + } + + @Test + void shouldRetryWithNotSupportedPropagationInsideOuterTransaction() throws Throwable { + TransactionSynchronizationManager.setActualTransactionActive(true); + + TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0, false); + interceptor.enqueueOutcome(new ConfigurableStatusException(BAD_SESSION), "ok"); + + Object result = interceptor.invoke(invocationFor("ydbNotSupportedRetry")); + + assertEquals("ok", result); + assertEquals(2, interceptor.allInvocations()); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/TransactionalDefaultRetryTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/TransactionalDefaultRetryTest.java new file mode 100644 index 00000000..79f8fb6b --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/TransactionalDefaultRetryTest.java @@ -0,0 +1,222 @@ +package tech.ydb.retry; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static tech.ydb.core.StatusCode.ABORTED; +import static tech.ydb.core.StatusCode.BAD_SESSION; +import static tech.ydb.core.StatusCode.CLIENT_INTERNAL_ERROR; +import static tech.ydb.core.StatusCode.TIMEOUT; +import static tech.ydb.core.StatusCode.UNAUTHORIZED; + +class TransactionalDefaultRetryTest extends InterceptorTestSupport { + + @Test + void shouldRetryWithDefaultConfigUntilSuccess() throws Throwable { + TestableInterceptor interceptor = interceptorWithConfig(true, 3, 0, 0, 0, 0, false); + interceptor.enqueueOutcome(new ConfigurableStatusException(BAD_SESSION), "ok"); + + Object result = interceptor.invoke(invocationFor("regularTx")); + + assertEquals("ok", result); + assertEquals(1, interceptor.retries()); + assertEquals(2, interceptor.allInvocations()); + } + + @Test + void shouldExhaustDefaultMaxRetriesAndPropagateLastException() { + TestableInterceptor interceptor = interceptorWithConfig(true, 2, 0, 0, 0, 0, false); + interceptor.enqueueOutcome(new ConfigurableStatusException(BAD_SESSION), new ConfigurableStatusException(ABORTED), new ConfigurableStatusException(ABORTED)); + + assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("regularTx")) + ); + + assertEquals(2, interceptor.retries()); + assertEquals(3, interceptor.allInvocations()); + } + + @Test + void shouldPropagateNonRetryableExceptionImmediately() { + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, false); + interceptor.enqueueOutcome(new ConfigurableStatusException(UNAUTHORIZED)); + + ConfigurableStatusException exception = assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("regularTx")) + ); + + assertEquals(UNAUTHORIZED, exception.getStatus().getCode()); + assertEquals(0, interceptor.retries()); + assertEquals(1, interceptor.allInvocations()); + } + + @Test + void shouldNotRetryNonYdbRuntimeException() { + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, false); + interceptor.enqueueOutcome(new IllegalStateException("not ydb")); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> interceptor.invoke(invocationFor("regularTx")) + ); + + assertEquals("not ydb", exception.getMessage()); + assertEquals(0, interceptor.retries()); + assertEquals(1, interceptor.allInvocations()); + } + + @Test + void shouldImmediatelyPropagateJavaError() { + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, false); + interceptor.enqueueOutcome(new OutOfMemoryError("test oom")); + + assertThrows( + OutOfMemoryError.class, + () -> interceptor.invoke(invocationFor("regularTx")) + ); + assertEquals(1, interceptor.allInvocations()); + } + + @Test + void shouldRetryWhenYdbStatusExtractedFromExceptionChain() throws Throwable { + TestableInterceptor interceptor = interceptorWithConfig(true, 3, 0, 0, 0, 0, false); + interceptor.enqueueOutcome( + new RuntimeException("wrapped", new ConfigurableStatusException(BAD_SESSION)), "ok"); + + Object result = interceptor.invoke(invocationFor("regularTx")); + + assertEquals("ok", result); + assertEquals(2, interceptor.allInvocations()); + } + + @Test + void shouldCallSleeperWithBackoffDelay() throws Throwable { + List delays = new ArrayList<>(); + TestableInterceptor interceptor = interceptorWithSleeper(true, 5, 0, 0, 0, 0, false, delays::add); + interceptor.enqueueOutcome( + new ConfigurableStatusException(ABORTED), + new ConfigurableStatusException(ABORTED), + "ok" + ); + + Object result = interceptor.invoke(invocationFor("regularTx")); + + assertEquals("ok", result); + assertEquals(3, interceptor.allInvocations()); + assertEquals(2, delays.size()); + for (Long delay : delays) { + assertTrue(delay >= 0); + } + } + + @Test + void shouldUseZeroDelayForBadSession() throws Throwable { + List delays = new ArrayList<>(); + TestableInterceptor interceptor = interceptorWithSleeper(true, 5, 100, 50, 1000, 500, false, delays::add); + interceptor.enqueueOutcome(new ConfigurableStatusException(BAD_SESSION), "ok"); + + Object result = interceptor.invoke(invocationFor("regularTx")); + + assertEquals("ok", result); + assertEquals(2, interceptor.allInvocations()); + assertEquals(1, delays.size()); + assertEquals(0, delays.get(0)); + } + + @Test + void shouldHandleInterruptedSleep() { + ConfigurableStatusException originalException = new ConfigurableStatusException(CLIENT_INTERNAL_ERROR); + TestableInterceptor interceptor = interceptorWithSleeper( + true, 3, 0, 0, 0, 0, true, delay -> { + throw new InterruptedException("sleep interrupted"); + }); + interceptor.enqueueOutcome(originalException, "ok"); + + try { + InterruptedException thrown = assertThrows( + InterruptedException.class, + () -> interceptor.invoke(invocationFor("regularTx")) + ); + assertEquals("sleep interrupted", thrown.getMessage()); + assertEquals(1, thrown.getSuppressed().length); + assertSame(originalException, thrown.getSuppressed()[0]); + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + } + } + + @Test + void shouldNotRetryClientInternalErrorForTransactionalMethodWhenDefaultConfigNotIdempotent() { + TestableInterceptor interceptor = interceptorWithConfig(true, 3, 0, 0, 0, 0, false); + interceptor.enqueueOutcome(new ConfigurableStatusException(CLIENT_INTERNAL_ERROR), "ok"); + + ConfigurableStatusException exception = assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("regularTx")) + ); + + assertEquals(CLIENT_INTERNAL_ERROR, exception.getStatus().getCode()); + assertEquals(1, interceptor.allInvocations()); + } + + @Test + void shouldRetryClientInternalErrorForTransactionalMethodWhenDefaultConfigIdempotent() throws Throwable { + TestableInterceptor interceptor = interceptorWithConfig(true, 3, 0, 0, 0, 0, true); + interceptor.enqueueOutcome(new ConfigurableStatusException(CLIENT_INTERNAL_ERROR), "ok"); + + Object result = interceptor.invoke(invocationFor("regularTx")); + + assertEquals("ok", result); + assertEquals(2, interceptor.allInvocations()); + } + + @Test + void shouldNotRetryTimeoutForTransactionalMethodWhenDefaultConfigIdempotent() { + TestableInterceptor interceptor = interceptorWithConfig(true, 3, 0, 0, 0, 0, true); + interceptor.enqueueOutcome(new ConfigurableStatusException(TIMEOUT)); + + ConfigurableStatusException exception = assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("regularTx")) + ); + + assertEquals(TIMEOUT, exception.getStatus().getCode()); + assertEquals(1, interceptor.allInvocations()); + } + + @Test + void shouldNotRetryTimeoutForTransactionalMethodWhenDefaultConfigNotIdempotent() { + TestableInterceptor interceptor = interceptorWithConfig(true, 3, 0, 0, 0, 0, false); + interceptor.enqueueOutcome(new ConfigurableStatusException(TIMEOUT)); + + ConfigurableStatusException exception = assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("regularTx")) + ); + + assertEquals(TIMEOUT, exception.getStatus().getCode()); + assertEquals(1, interceptor.allInvocations()); + } + + @Test + void shouldNotRetryWhenDisabledInConfig() { + TestableInterceptor interceptor = interceptorWithConfig(false, 3, 0, 0, 0, 0, false); + interceptor.enqueueOutcome(new ConfigurableStatusException(BAD_SESSION), "ok"); + + ConfigurableStatusException exception = assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("regularTx")) + ); + + assertEquals(BAD_SESSION, exception.getStatus().getCode()); + assertEquals(1, interceptor.allInvocations()); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbRetryPolicyConfigTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbRetryPolicyConfigTest.java new file mode 100644 index 00000000..48d6e269 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbRetryPolicyConfigTest.java @@ -0,0 +1,311 @@ +package tech.ydb.retry; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.Test; +import org.springframework.core.annotation.AnnotatedElementUtils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static tech.ydb.retry.YdbRetryPolicyConfig.DEFAULT_FAST_BACKOFF_BASE_MS; +import static tech.ydb.retry.YdbRetryPolicyConfig.DEFAULT_FAST_CAP_BACKOFF_MS; +import static tech.ydb.retry.YdbRetryPolicyConfig.DEFAULT_MAX_RETRIES; +import static tech.ydb.retry.YdbRetryPolicyConfig.DEFAULT_SLOW_BACKOFF_BASE_MS; +import static tech.ydb.retry.YdbRetryPolicyConfig.DEFAULT_SLOW_CAP_BACKOFF_MS; + +class YdbRetryPolicyConfigTest extends InterceptorTestSupport { + + @Test + void defaultConstructorShouldSetDefaultValues() { + YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(); + + assertEquals(DEFAULT_MAX_RETRIES, config.getMaxRetries()); + assertEquals(DEFAULT_SLOW_BACKOFF_BASE_MS, config.getSlowBackoffBaseMs()); + assertEquals(DEFAULT_FAST_BACKOFF_BASE_MS, config.getFastBackoffBaseMs()); + assertEquals(DEFAULT_SLOW_CAP_BACKOFF_MS, config.getSlowCapBackoffMs()); + assertEquals(DEFAULT_FAST_CAP_BACKOFF_MS, config.getFastCapBackoffMs()); + } + + @Test + void customConstructorShouldSetValues() { + YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300); + + assertEquals(5, config.getMaxRetries()); + assertEquals(100, config.getSlowBackoffBaseMs()); + assertEquals(20, config.getFastBackoffBaseMs()); + assertEquals(2000, config.getSlowCapBackoffMs()); + assertEquals(300, config.getFastCapBackoffMs()); + } + + @Test + void shouldThrowWhenMaxRetriesIsZero() { + assertThrows(IllegalArgumentException.class, () -> new YdbRetryPolicyConfig(true, 0, 0, 0, 0, 0)); + } + + @Test + void shouldThrowWhenMaxRetriesIsNegative() { + assertThrows(IllegalArgumentException.class, () -> new YdbRetryPolicyConfig(true, -1, 0, 0, 0, 0)); + } + + @Test + void shouldThrowWhenSlowBackoffBaseIsNegative() { + assertThrows(IllegalArgumentException.class, () -> new YdbRetryPolicyConfig(true, 1, -1, 0, 0, 0)); + } + + @Test + void shouldThrowWhenFastBackoffBaseIsNegative() { + assertThrows(IllegalArgumentException.class, () -> new YdbRetryPolicyConfig(true, 1, 0, -1, 0, 0)); + } + + @Test + void shouldThrowWhenSlowCapIsNegative() { + assertThrows(IllegalArgumentException.class, () -> new YdbRetryPolicyConfig(true, 1, 0, 0, -1, 0)); + } + + @Test + void shouldThrowWhenFastCapIsNegative() { + assertThrows(IllegalArgumentException.class, () -> new YdbRetryPolicyConfig(true, 1, 0, 0, 0, -1)); + } + + @Test + void mergeWithNullShouldReturnSameInstance() { + YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(); + assertSame(config, config.merge(null)); + } + + @Test + void mergeWithDefaultAnnotationShouldKeepConfigValues() throws NoSuchMethodException { + YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300); + + Method method = YdbTransactionalTestService.class.getMethod("defaultRetry"); + YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); + + + YdbRetryPolicyConfig merged = original.merge(annotation); + + assertEquals(5, merged.getMaxRetries()); + assertEquals(100, merged.getSlowBackoffBaseMs()); + assertEquals(20, merged.getFastBackoffBaseMs()); + assertEquals(2000, merged.getSlowCapBackoffMs()); + assertEquals(300, merged.getFastCapBackoffMs()); + } + + @Test + void mergeWithCustomAnnotationShouldOverride() throws NoSuchMethodException { + YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300); + + Method method = YdbTransactionalTestService.class.getMethod("ydbNewTransactionSettings"); + YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); + + YdbRetryPolicyConfig merged = original.merge(annotation); + + assertEquals(100, merged.getMaxRetries()); + assertEquals(200, merged.getSlowBackoffBaseMs()); + assertEquals(10, merged.getFastBackoffBaseMs()); + assertEquals(10000, merged.getSlowCapBackoffMs()); + assertEquals(12, merged.getFastCapBackoffMs()); + } + + @Test + void mergeWithPartialOverrideShouldOnlyChangeSpecifiedValues() throws NoSuchMethodException { + YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300); + + Method method = YdbTransactionalTestService.class.getMethod("ydbCustomRetry"); + YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); + + YdbRetryPolicyConfig merged = original.merge(annotation); + + // only maxRetries should change + assertEquals(2, merged.getMaxRetries()); + assertEquals(100, merged.getSlowBackoffBaseMs()); + assertEquals(20, merged.getFastBackoffBaseMs()); + assertEquals(2000, merged.getSlowCapBackoffMs()); + assertEquals(300, merged.getFastCapBackoffMs()); + } + + @Test + void shouldThrowWhenYdbTransactionalMaxRetriesIsNegative() throws NoSuchMethodException { + YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300); + + Method method = YdbTransactionalTestService.class.getMethod("ydbNegativeMaxRetries"); + YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); + + assertThrows(IllegalArgumentException.class, () -> original.merge(annotation)); + } + + @Test + void getJitterShouldReturnValueWithinRange() { + YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(); + long bound = 100; + for (int i = 0; i < 50; i++) { + long jitter = config.getJitter(bound); + assertTrue(jitter >= 0 && jitter < bound, + "Jitter " + jitter + " out of range [0, " + bound + ")"); + } + } + + @Test + void powShouldBeComputedFromCapValues() { + YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300); + + int expectedSlowPow = (int) (Math.log(2000) / Math.log(2)); + int expectedFastPow = (int) (Math.log(300) / Math.log(2)); + + assertEquals(expectedSlowPow, config.getSlowPow()); + assertEquals(expectedFastPow, config.getFastPow()); + } + + @Test + void powForSmallCapShouldBeOne() { + YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 1, 0, 0, 1, 1); + assertEquals(1, config.getSlowPow()); + assertEquals(1, config.getFastPow()); + } + + @Test + void powForZeroCapShouldBeOne() { + YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 1, 0, 0, 0, 0); + assertEquals(1, config.getSlowPow()); + assertEquals(1, config.getFastPow()); + } + + @Test + void defaultConstructorShouldSetIdempotentFalse() { + YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(); + assertFalse(config.isIdempotent()); + } + + @Test + void fiveArgConstructorShouldSetIdempotentFalse() { + YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300); + assertFalse(config.isIdempotent()); + } + + @Test + void sixArgConstructorShouldSetIdempotentTrue() { + YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300, true); + assertTrue(config.isIdempotent()); + } + + @Test + void mergeWithIdempotentAnnotationShouldSetIdempotentTrue() throws NoSuchMethodException { + YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300, false); + + Method method = YdbTransactionalTestService.class.getMethod("ydbIdempotentRetry"); + YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); + + YdbRetryPolicyConfig merged = original.merge(annotation); + + assertTrue(merged.isIdempotent()); + } + + @Test + void mergeWithNonIdempotentAnnotationShouldSetIdempotentFalse() throws NoSuchMethodException { + YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300, true); + + Method method = YdbTransactionalTestService.class.getMethod("ydbNonIdempotentRetry"); + YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); + + YdbRetryPolicyConfig merged = original.merge(annotation); + + assertFalse(merged.isIdempotent()); + } + + @Test + void mergeWithDefaultAnnotationShouldInheritIdempotentFromConfig() throws NoSuchMethodException { + YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300, true); + + Method method = YdbTransactionalTestService.class.getMethod("defaultRetry"); + YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); + + YdbRetryPolicyConfig merged = original.merge(annotation); + + assertTrue(merged.isIdempotent()); + } + + @Test + void mergeWithDefaultAnnotationShouldInheritIdempotentFalseFromConfig() throws NoSuchMethodException { + YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300, false); + + Method method = YdbTransactionalTestService.class.getMethod("defaultRetry"); + YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); + + YdbRetryPolicyConfig merged = original.merge(annotation); + + assertFalse(merged.isIdempotent()); + } + + @Test + void defaultConstructorShouldSetEnabledTrue() { + YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(); + assertTrue(config.isEnabled()); + } + + @Test + void constructorShouldSetEnabledFalse() { + YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(false, 5, 100, 20, 2000, 300); + assertFalse(config.isEnabled()); + } + + @Test + void mergeShouldPreserveEnabledFromBaseConfig() throws NoSuchMethodException { + YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(false, 5, 100, 20, 2000, 300); + + Method method = YdbTransactionalTestService.class.getMethod("ydbCustomRetry"); + YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); + + YdbRetryPolicyConfig merged = original.merge(annotation); + + assertFalse(merged.isEnabled()); + } + + @Test + void mergeShouldKeepEnabledTrueWhenConfigEnabled() throws NoSuchMethodException { + YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300); + + Method method = YdbTransactionalTestService.class.getMethod("defaultRetry"); + YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); + + YdbRetryPolicyConfig merged = original.merge(annotation); + + assertTrue(merged.isEnabled()); + } + + @Test + void mergeWithDefaultAnnotationShouldKeepEnabledFalseWhenConfigDisabled() throws NoSuchMethodException { + YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(false, 5, 100, 20, 2000, 300); + + Method method = YdbTransactionalTestService.class.getMethod("defaultRetry"); + YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); + + YdbRetryPolicyConfig merged = original.merge(annotation); + + assertFalse(merged.isEnabled()); + } + + @Test + void mergeWithDisabledAnnotationShouldSetEnabledFalse() throws NoSuchMethodException { + YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300); + + Method method = YdbTransactionalTestService.class.getMethod("ydbRetryDisabled"); + YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); + + YdbRetryPolicyConfig merged = original.merge(annotation); + + assertFalse(merged.isEnabled()); + } + + @Test + void mergeWithEnabledAnnotationShouldNotOverrideDisabledGlobalConfig() throws NoSuchMethodException { + YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(false, 5, 100, 20, 2000, 300); + + Method method = YdbTransactionalTestService.class.getMethod("ydbRetryEnabled"); + YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); + + YdbRetryPolicyConfig merged = original.merge(annotation); + + assertFalse(merged.isEnabled()); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbRetryPolicyTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbRetryPolicyTest.java new file mode 100644 index 00000000..8051f0e2 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbRetryPolicyTest.java @@ -0,0 +1,105 @@ +package tech.ydb.retry; + +import java.util.List; +import org.junit.jupiter.api.Test; +import tech.ydb.core.StatusCode; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static tech.ydb.core.StatusCode.ABORTED; +import static tech.ydb.core.StatusCode.BAD_REQUEST; +import static tech.ydb.core.StatusCode.BAD_SESSION; +import static tech.ydb.core.StatusCode.CANCELLED; +import static tech.ydb.core.StatusCode.CLIENT_CANCELLED; +import static tech.ydb.core.StatusCode.CLIENT_INTERNAL_ERROR; +import static tech.ydb.core.StatusCode.CLIENT_RESOURCE_EXHAUSTED; +import static tech.ydb.core.StatusCode.EXTERNAL_ERROR; +import static tech.ydb.core.StatusCode.GENERIC_ERROR; +import static tech.ydb.core.StatusCode.INTERNAL_ERROR; +import static tech.ydb.core.StatusCode.NOT_FOUND; +import static tech.ydb.core.StatusCode.OVERLOADED; +import static tech.ydb.core.StatusCode.SCHEME_ERROR; +import static tech.ydb.core.StatusCode.SESSION_BUSY; +import static tech.ydb.core.StatusCode.SESSION_EXPIRED; +import static tech.ydb.core.StatusCode.TIMEOUT; +import static tech.ydb.core.StatusCode.TRANSPORT_UNAVAILABLE; +import static tech.ydb.core.StatusCode.UNAUTHORIZED; +import static tech.ydb.core.StatusCode.UNAVAILABLE; +import static tech.ydb.core.StatusCode.UNDETERMINED; +import static tech.ydb.core.StatusCode.UNSUPPORTED; + +class YdbRetryPolicyTest { + @Test + void shouldRetryAlwaysRetryableStatusesRegardlessOfIdempotence() { + List alwaysRetryable = List.of( + BAD_SESSION, + SESSION_BUSY, + ABORTED, + UNAVAILABLE, + OVERLOADED, + CLIENT_RESOURCE_EXHAUSTED + ); + + for (StatusCode code : alwaysRetryable) { + assertTrue(YdbRetryPolicy.shouldRetry(code, false), "Should retry " + code + " when not idempotent"); + assertTrue(YdbRetryPolicy.shouldRetry(code, true), "Should retry " + code + " when idempotent"); + } + } + + @Test + void shouldNotRetryIdempotentOnlyStatusesWhenNotIdempotent() { + List idempotentOnly = List.of( + CLIENT_CANCELLED, + CLIENT_INTERNAL_ERROR, + TRANSPORT_UNAVAILABLE, + UNDETERMINED + ); + + for (StatusCode code : idempotentOnly) { + assertFalse(YdbRetryPolicy.shouldRetry(code, false), "Should not retry " + code + " when not idempotent"); + } + } + + @Test + void shouldRetryIdempotentOnlyStatusesWhenIdempotent() { + List idempotentOnly = List.of( + CLIENT_CANCELLED, + CLIENT_INTERNAL_ERROR, + TRANSPORT_UNAVAILABLE, + UNDETERMINED + ); + + for (StatusCode code : idempotentOnly) { + assertTrue(YdbRetryPolicy.shouldRetry(code, true), "Should retry " + code + " when idempotent"); + } + } + + @Test + void shouldNotRetryNonRetryableStatuses() { + List nonRetryable = List.of( + StatusCode.SUCCESS, + BAD_REQUEST, + UNAUTHORIZED, + INTERNAL_ERROR, + SCHEME_ERROR, + GENERIC_ERROR, + NOT_FOUND, + UNSUPPORTED, + CANCELLED, + EXTERNAL_ERROR, + TIMEOUT, + SESSION_EXPIRED + ); + + for (StatusCode code : nonRetryable) { + assertFalse(YdbRetryPolicy.shouldRetry(code, false), "Should not retry " + code + " when not idempotent"); + assertFalse(YdbRetryPolicy.shouldRetry(code, true), "Should not retry " + code + " when idempotent"); + } + } + + @Test + void shouldNotRetryNullStatusCode() { + assertFalse(YdbRetryPolicy.shouldRetry(null, false)); + assertFalse(YdbRetryPolicy.shouldRetry(null, true)); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorFactoryTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorFactoryTest.java new file mode 100644 index 00000000..5c91858f --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorFactoryTest.java @@ -0,0 +1,104 @@ +package tech.ydb.retry; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionAttributeSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class YdbTransactionInterceptorFactoryTest { + + @Test + void getObjectTypeShouldReturnYdbTransactionInterceptorClass() { + YdbTransactionInterceptorFactory factory = new YdbTransactionInterceptorFactory(); + + assertEquals(YdbTransactionInterceptor.class, factory.getObjectType()); + } + + @Test + void getObjectShouldReturnYdbTransactionInterceptor() { + YdbTransactionInterceptorFactory factory = createYdbTransactionInterceptorFactory(); + YdbTransactionInterceptor interceptor = factory.getObject(); + + assertNotNull(interceptor); + } + + @Test + void getObjectShouldUseRetryPropertiesConfig() { + YdbRetryProperties properties = new YdbRetryProperties(); + properties.setEnabled(false); + properties.setMaxRetries(3); + properties.setIdempotent(true); + YdbTransactionInterceptorFactory factory = new YdbTransactionInterceptorFactory(); + factory.setRetryProperties(properties); + factory.setTransactionAttributeSource(new AnnotationTransactionAttributeSource()); + + assertNotNull(factory.getObject()); + } + + @Test + void getObjectShouldThrowNpeWhenRetryPropertiesIsNull() { + YdbTransactionInterceptorFactory factory = new YdbTransactionInterceptorFactory(); + factory.setTransactionAttributeSource(new AnnotationTransactionAttributeSource()); + + assertThrows(NullPointerException.class, factory::getObject); + } + + @Test + void getObjectShouldSetTransactionAttributeSource() { + TransactionAttributeSource tas = new AnnotationTransactionAttributeSource(); + YdbTransactionInterceptorFactory factory = new YdbTransactionInterceptorFactory(); + factory.setRetryProperties(new YdbRetryProperties()); + factory.setTransactionAttributeSource(tas); + + YdbTransactionInterceptor interceptor = factory.getObject(); + + assertNotNull(interceptor); + assertSame(tas, interceptor.getTransactionAttributeSource()); + } + + @Test + void getObjectShouldLeaveTransactionManagerUnsetForDeferredResolution() { + YdbTransactionInterceptorFactory factory = createYdbTransactionInterceptorFactory(); + + YdbTransactionInterceptor interceptor = factory.getObject(); + + assertNotNull(interceptor); + assertNull(interceptor.getTransactionManager()); + } + + @Test + void getObjectShouldCreateInterceptorWhenBeanFactoryIsProvided() { + YdbTransactionInterceptorFactory factory = createYdbTransactionInterceptorFactory(); + factory.setBeanFactory(new DefaultListableBeanFactory()); + + YdbTransactionInterceptor interceptor = factory.getObject(); + + assertNotNull(interceptor); + } + + @Test + void getObjectShouldCreateNewInstanceOnEachCall() { + YdbTransactionInterceptorFactory factory = createYdbTransactionInterceptorFactory(); + + YdbTransactionInterceptor first = factory.getObject(); + YdbTransactionInterceptor second = factory.getObject(); + + assertNotNull(first); + assertNotNull(second); + assertNotSame(first, second); + } + + private static YdbTransactionInterceptorFactory createYdbTransactionInterceptorFactory() { + YdbTransactionInterceptorFactory factory = new YdbTransactionInterceptorFactory(); + factory.setRetryProperties(new YdbRetryProperties()); + factory.setTransactionAttributeSource(new AnnotationTransactionAttributeSource()); + return factory; + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorReplacerTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorReplacerTest.java new file mode 100644 index 00000000..ead4da6c --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorReplacerTest.java @@ -0,0 +1,168 @@ +package tech.ydb.retry; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.support.AbstractBeanDefinition; +import org.springframework.beans.factory.support.AutowireCandidateQualifier; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.core.Ordered.LOWEST_PRECEDENCE; + +class YdbTransactionInterceptorReplacerTest { + + @Test + void shouldHaveLowestPrecedenceOrder() { + YdbTransactionInterceptorReplacer pp = new YdbTransactionInterceptorReplacer(); + assertEquals(LOWEST_PRECEDENCE, pp.getOrder()); + } + + @Test + void shouldSkipWhenTransactionInterceptorNotFound() { + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); + YdbTransactionInterceptorReplacer pp = new YdbTransactionInterceptorReplacer(); + pp.postProcessBeanDefinitionRegistry(beanFactory); + + assertFalse(beanFactory.containsBeanDefinition("transactionInterceptor")); + } + + @Test + void shouldSkipWhenAlreadyYdbTransactionInterceptorFactory() { + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); + BeanDefinition beanDefinition = BeanDefinitionBuilder.genericBeanDefinition(YdbTransactionInterceptorFactory.class).getBeanDefinition(); + beanFactory.registerBeanDefinition("transactionInterceptor", beanDefinition); + + YdbTransactionInterceptorReplacer pp = new YdbTransactionInterceptorReplacer(); + pp.postProcessBeanDefinitionRegistry(beanFactory); + + String beanClassName = beanFactory.getBeanDefinition("transactionInterceptor").getBeanClassName(); + assertEquals(YdbTransactionInterceptorFactory.class.getName(), beanClassName); + } + + @Test + void shouldReplaceStandardTransactionInterceptorBeanDefinition() { + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); + BeanDefinition beanDefinition = BeanDefinitionBuilder.genericBeanDefinition(TransactionInterceptor.class).getBeanDefinition(); + beanFactory.registerBeanDefinition("transactionInterceptor", beanDefinition); + + PlatformTransactionManager txManager = Mockito.mock(PlatformTransactionManager.class); + YdbRetryProperties properties = new YdbRetryProperties(); + TransactionAttributeSource tas = new AnnotationTransactionAttributeSource(); + + beanFactory.registerSingleton("transactionManager", txManager); + beanFactory.registerSingleton(YdbRetryProperties.class.getName(), properties); + beanFactory.registerSingleton(TransactionAttributeSource.class.getName(), tas); + + YdbTransactionInterceptorReplacer pp = new YdbTransactionInterceptorReplacer(); + pp.postProcessBeanDefinitionRegistry(beanFactory); + + beanDefinition = beanFactory.getBeanDefinition("transactionInterceptor"); + assertEquals(YdbTransactionInterceptorFactory.class.getName(), beanDefinition.getBeanClassName()); + + Object bean = beanFactory.getBean("transactionInterceptor"); + assertInstanceOf(YdbTransactionInterceptor.class, bean); + + Map interceptors = beanFactory.getBeansOfType(TransactionInterceptor.class); + assertEquals(1, interceptors.size()); + assertSame(bean, interceptors.get("transactionInterceptor")); + } + + @Test + void shouldRegisterInterceptorWithCorrectProperties() { + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); + BeanDefinition beanDefinition = BeanDefinitionBuilder.genericBeanDefinition(TransactionInterceptor.class).getBeanDefinition(); + beanFactory.registerBeanDefinition("transactionInterceptor", beanDefinition); + + PlatformTransactionManager txManager = Mockito.mock(PlatformTransactionManager.class); + YdbRetryProperties properties = new YdbRetryProperties(); + properties.setEnabled(false); + properties.setMaxRetries(3); + TransactionAttributeSource tas = new AnnotationTransactionAttributeSource(); + + beanFactory.registerSingleton("transactionManager", txManager); + beanFactory.registerSingleton(YdbRetryProperties.class.getName(), properties); + beanFactory.registerSingleton(TransactionAttributeSource.class.getName(), tas); + + YdbTransactionInterceptorReplacer pp = new YdbTransactionInterceptorReplacer(); + pp.postProcessBeanDefinitionRegistry(beanFactory); + + Object bean = beanFactory.getBean("transactionInterceptor"); + assertInstanceOf(YdbTransactionInterceptor.class, bean); + } + + @Test + void shouldPreserveBeanDefinitionMetadataWhenReplacingInterceptor() { + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); + AbstractBeanDefinition beanDefinition = BeanDefinitionBuilder + .genericBeanDefinition(TransactionInterceptor.class) + .getBeanDefinition(); + beanDefinition.setPrimary(true); + beanDefinition.setFallback(true); + beanDefinition.setLazyInit(true); + beanDefinition.setDependsOn("txDependency"); + beanDefinition.setParentName("txParent"); + beanDefinition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + beanDefinition.setScope(BeanDefinition.SCOPE_PROTOTYPE); + beanDefinition.setDescription("transaction interceptor"); + beanDefinition.setDefaultCandidate(false); + beanDefinition.setSynthetic(true); + beanDefinition.setInitMethodNames("initInterceptor"); + beanDefinition.setDestroyMethodNames("destroyInterceptor"); + beanDefinition.addQualifier(new AutowireCandidateQualifier(String.class)); + beanDefinition.setAttribute("preserveTargetClass", true); + ByteArrayResource resource = new ByteArrayResource(new byte[0], "tx-resource"); + beanDefinition.setResource(resource); + beanDefinition.setResourceDescription("tx-resource-description"); + BeanDefinition originatingBeanDefinition = BeanDefinitionBuilder.genericBeanDefinition(Object.class).getBeanDefinition(); + beanDefinition.setOriginatingBeanDefinition(originatingBeanDefinition); + Object source = new Object(); + beanDefinition.setSource(source); + beanFactory.registerBeanDefinition("txParent", BeanDefinitionBuilder.genericBeanDefinition(Object.class).getBeanDefinition()); + beanFactory.registerBeanDefinition("txDependency", BeanDefinitionBuilder.genericBeanDefinition(Object.class).getBeanDefinition()); + beanFactory.registerBeanDefinition("transactionInterceptor", beanDefinition); + beanFactory.registerSingleton(YdbRetryProperties.class.getName(), new YdbRetryProperties()); + beanFactory.registerSingleton(TransactionAttributeSource.class.getName(), new AnnotationTransactionAttributeSource()); + + YdbTransactionInterceptorReplacer pp = new YdbTransactionInterceptorReplacer(); + pp.postProcessBeanDefinitionRegistry(beanFactory); + + AbstractBeanDefinition replaced = (AbstractBeanDefinition) beanFactory.getBeanDefinition("transactionInterceptor"); + assertEquals(YdbTransactionInterceptorFactory.class.getName(), replaced.getBeanClassName()); + assertTrue(replaced.isPrimary()); + assertTrue(replaced.isFallback()); + assertTrue(replaced.isLazyInit()); + assertArrayEquals(new String[]{"txDependency"}, replaced.getDependsOn()); + assertEquals("txParent", replaced.getParentName()); + assertEquals(BeanDefinition.ROLE_INFRASTRUCTURE, replaced.getRole()); + assertEquals(BeanDefinition.SCOPE_PROTOTYPE, replaced.getScope()); + assertEquals("transaction interceptor", replaced.getDescription()); + assertFalse(replaced.isDefaultCandidate()); + assertTrue(replaced.isSynthetic()); + assertNull(replaced.getInitMethodNames()); + assertNull(replaced.getDestroyMethodNames()); + assertTrue(replaced.hasQualifier(String.class.getName())); + assertEquals(true, replaced.getAttribute("preserveTargetClass")); + assertEquals(beanDefinition.getResource().getClass(), replaced.getResource().getClass()); + assertEquals(beanDefinition.getResource().getDescription(), replaced.getResource().getDescription()); + assertEquals(beanDefinition.getResourceDescription(), replaced.getResourceDescription()); + assertSame(originatingBeanDefinition, replaced.getOriginatingBeanDefinition()); + assertSame(source, replaced.getSource()); + + Object bean = beanFactory.getBean("transactionInterceptor"); + assertInstanceOf(YdbTransactionInterceptor.class, bean); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionManagerResolutionTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionManagerResolutionTest.java new file mode 100644 index 00000000..97af161e --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionManagerResolutionTest.java @@ -0,0 +1,339 @@ +package tech.ydb.retry; + +import java.lang.reflect.Method; +import java.util.concurrent.atomic.AtomicInteger; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionManager; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.annotation.TransactionManagementConfigurer; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.interceptor.TransactionAttribute; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.SimpleTransactionStatus; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class YdbTransactionManagerResolutionTest { + + @Test + void shouldUseSingleManager() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(SingleManagerConfig.class)) { + SingleManagerService service = context.getBean(SingleManagerService.class); + RecordingTransactionManager manager = context.getBean("singleManager", RecordingTransactionManager.class); + + service.defaultOperation(); + + assertEquals(1, manager.beginCount()); + assertEquals(1, manager.commitCount()); + assertEquals(0, manager.rollbackCount()); + assertInstanceOf(YdbTransactionInterceptor.class, context.getBean("transactionInterceptor")); + assertEquals(1, context.getBeansOfType(TransactionInterceptor.class).size()); + } + } + + @Test + void shouldResolveExplicitTransactionManagersWithoutPrimary() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(MultiManagerConfig.class)) { + MultiManagerService service = context.getBean(MultiManagerService.class); + RecordingTransactionManager ydbManager = context.getBean("ydbTransactionManager", RecordingTransactionManager.class); + RecordingTransactionManager auditManager = context.getBean("auditTransactionManager", RecordingTransactionManager.class); + + service.ydbOperation(); + + assertEquals(1, ydbManager.beginCount()); + assertEquals(1, ydbManager.commitCount()); + assertEquals(0, ydbManager.rollbackCount()); + assertEquals(0, auditManager.beginCount()); + assertEquals(0, auditManager.commitCount()); + assertEquals(0, auditManager.rollbackCount()); + + ydbManager.reset(); + auditManager.reset(); + + service.auditOperation(); + + assertEquals(0, ydbManager.beginCount()); + assertEquals(0, ydbManager.commitCount()); + assertEquals(0, ydbManager.rollbackCount()); + assertEquals(1, auditManager.beginCount()); + assertEquals(1, auditManager.commitCount()); + assertEquals(0, auditManager.rollbackCount()); + assertInstanceOf(YdbTransactionInterceptor.class, context.getBean("transactionInterceptor")); + assertEquals(1, context.getBeansOfType(TransactionInterceptor.class).size()); + } + } + + @Test + void shouldUseConfigurerDefaultTransactionManager() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(ConfigurerDefaultManagerConfig.class)) { + ConfigurerDefaultManagerService service = context.getBean(ConfigurerDefaultManagerService.class); + RecordingTransactionManager ydbManager = context.getBean("ydbTransactionManager", RecordingTransactionManager.class); + RecordingTransactionManager auditManager = context.getBean("auditTransactionManager", RecordingTransactionManager.class); + + service.defaultSpringOperation(); + + assertEquals(0, ydbManager.beginCount()); + assertEquals(0, ydbManager.commitCount()); + assertEquals(1, auditManager.beginCount()); + assertEquals(1, auditManager.commitCount()); + + ydbManager.reset(); + auditManager.reset(); + + service.defaultYdbOperation(); + + assertEquals(0, ydbManager.beginCount()); + assertEquals(0, ydbManager.commitCount()); + assertEquals(1, auditManager.beginCount()); + assertEquals(1, auditManager.commitCount()); + } + } + + @Test + void shouldUsePrimaryTransactionManager() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(PrimaryManagerConfig.class)) { + PrimaryManagerService service = context.getBean(PrimaryManagerService.class); + RecordingTransactionManager primaryManager = context.getBean("primaryTransactionManager", RecordingTransactionManager.class); + RecordingTransactionManager secondaryManager = context.getBean("secondaryTransactionManager", RecordingTransactionManager.class); + + service.defaultSpringOperation(); + + assertEquals(1, primaryManager.beginCount()); + assertEquals(1, primaryManager.commitCount()); + assertEquals(0, secondaryManager.beginCount()); + assertEquals(0, secondaryManager.commitCount()); + + primaryManager.reset(); + secondaryManager.reset(); + + service.defaultYdbOperation(); + + assertEquals(1, primaryManager.beginCount()); + assertEquals(1, primaryManager.commitCount()); + assertEquals(0, secondaryManager.beginCount()); + assertEquals(0, secondaryManager.commitCount()); + } + } + + @Test + void ydbTransactionalAliasShouldExposeTransactionManagerQualifier() throws NoSuchMethodException { + AnnotationTransactionAttributeSource attributeSource = new AnnotationTransactionAttributeSource(); + Method ydbMethod = MultiManagerService.class.getMethod("ydbOperation"); + Method auditMethod = MultiManagerService.class.getMethod("auditOperation"); + + TransactionAttribute ydbAttribute = attributeSource.getTransactionAttribute(ydbMethod, MultiManagerService.class); + TransactionAttribute auditAttribute = attributeSource.getTransactionAttribute(auditMethod, MultiManagerService.class); + + assertNotNull(ydbAttribute); + assertNotNull(auditAttribute); + assertEquals("ydbTransactionManager", ydbAttribute.getQualifier()); + assertEquals("auditTransactionManager", auditAttribute.getQualifier()); + } + + @Test + void ydbTransactionalValueAliasShouldExposeTransactionManagerQualifier() throws NoSuchMethodException { + AnnotationTransactionAttributeSource attributeSource = new AnnotationTransactionAttributeSource(); + Method method = MultiManagerService.class.getMethod("ydbValueAliasOperation"); + + TransactionAttribute attribute = attributeSource.getTransactionAttribute(method, MultiManagerService.class); + + assertNotNull(attribute); + assertEquals("ydbTransactionManager", attribute.getQualifier()); + } + + @Test + void ydbTransactionalTimeoutStringShouldExposeTimeout() throws NoSuchMethodException { + AnnotationTransactionAttributeSource attributeSource = new AnnotationTransactionAttributeSource(); + Method method = MultiManagerService.class.getMethod("ydbTimeoutStringOperation"); + + TransactionAttribute attribute = attributeSource.getTransactionAttribute(method, MultiManagerService.class); + + assertNotNull(attribute); + assertEquals(15, attribute.getTimeout()); + } + + @Configuration(proxyBeanMethods = false) + @EnableTransactionManagement + @Import(YdbTransactionAutoConfiguration.class) + static class SingleManagerConfig { + + @Bean("singleManager") + RecordingTransactionManager singleManager() { + return new RecordingTransactionManager(); + } + + @Bean + SingleManagerService singleManagerService() { + return new SingleManagerService(); + } + } + + @Configuration(proxyBeanMethods = false) + @EnableTransactionManagement + @Import(YdbTransactionAutoConfiguration.class) + static class MultiManagerConfig { + + @Bean("ydbTransactionManager") + RecordingTransactionManager ydbTransactionManager() { + return new RecordingTransactionManager(); + } + + @Bean("auditTransactionManager") + RecordingTransactionManager auditTransactionManager() { + return new RecordingTransactionManager(); + } + + @Bean + MultiManagerService multiManagerService() { + return new MultiManagerService(); + } + } + + @Configuration + @EnableTransactionManagement + @Import(YdbTransactionAutoConfiguration.class) + static class ConfigurerDefaultManagerConfig implements TransactionManagementConfigurer { + + @Bean("ydbTransactionManager") + RecordingTransactionManager ydbTransactionManager() { + return new RecordingTransactionManager(); + } + + @Bean("auditTransactionManager") + RecordingTransactionManager auditTransactionManager() { + return new RecordingTransactionManager(); + } + + @Bean + ConfigurerDefaultManagerService configurerDefaultManagerService() { + return new ConfigurerDefaultManagerService(); + } + + @Override + public @NotNull TransactionManager annotationDrivenTransactionManager() { + return auditTransactionManager(); + } + } + + @Configuration(proxyBeanMethods = false) + @EnableTransactionManagement + @Import(YdbTransactionAutoConfiguration.class) + static class PrimaryManagerConfig { + + @Bean("primaryTransactionManager") + @Primary + RecordingTransactionManager primaryTransactionManager() { + return new RecordingTransactionManager(); + } + + @Bean("secondaryTransactionManager") + RecordingTransactionManager secondaryTransactionManager() { + return new RecordingTransactionManager(); + } + + @Bean + PrimaryManagerService primaryManagerService() { + return new PrimaryManagerService(); + } + } + + static class SingleManagerService { + + @Transactional + public void defaultOperation() { + } + } + + static class MultiManagerService { + + @YdbTransactional(transactionManager = "ydbTransactionManager") + public void ydbOperation() { + } + + @YdbTransactional("ydbTransactionManager") + public void ydbValueAliasOperation() { + } + + @YdbTransactional(timeoutString = "15") + public void ydbTimeoutStringOperation() { + } + + @Transactional(transactionManager = "auditTransactionManager") + public void auditOperation() { + } + } + + static class ConfigurerDefaultManagerService { + + @Transactional + public void defaultSpringOperation() { + } + + @YdbTransactional + public void defaultYdbOperation() { + } + } + + static class PrimaryManagerService { + + @Transactional + public void defaultSpringOperation() { + } + + @YdbTransactional + public void defaultYdbOperation() { + } + } + + static final class RecordingTransactionManager implements PlatformTransactionManager { + private final AtomicInteger beginCount = new AtomicInteger(); + private final AtomicInteger commitCount = new AtomicInteger(); + private final AtomicInteger rollbackCount = new AtomicInteger(); + + @Override + public TransactionStatus getTransaction(TransactionDefinition definition) { + beginCount.incrementAndGet(); + return new SimpleTransactionStatus(); + } + + @Override + public void commit(TransactionStatus status) { + commitCount.incrementAndGet(); + } + + @Override + public void rollback(TransactionStatus status) { + rollbackCount.incrementAndGet(); + } + + int beginCount() { + return beginCount.get(); + } + + int commitCount() { + return commitCount.get(); + } + + int rollbackCount() { + return rollbackCount.get(); + } + + void reset() { + beginCount.set(0); + commitCount.set(0); + rollbackCount.set(0); + } + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionalConfigOverrideTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionalConfigOverrideTest.java new file mode 100644 index 00000000..56dd8906 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionalConfigOverrideTest.java @@ -0,0 +1,333 @@ +package tech.ydb.retry; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static tech.ydb.core.StatusCode.ABORTED; +import static tech.ydb.core.StatusCode.BAD_SESSION; +import static tech.ydb.core.StatusCode.CLIENT_CANCELLED; +import static tech.ydb.core.StatusCode.CLIENT_RESOURCE_EXHAUSTED; +import static tech.ydb.core.StatusCode.OVERLOADED; +import static tech.ydb.core.StatusCode.SESSION_BUSY; +import static tech.ydb.core.StatusCode.SESSION_EXPIRED; +import static tech.ydb.core.StatusCode.TIMEOUT; +import static tech.ydb.core.StatusCode.TRANSPORT_UNAVAILABLE; +import static tech.ydb.core.StatusCode.UNDETERMINED; + +class YdbTransactionalConfigOverrideTest extends InterceptorTestSupport { + + @Test + void shouldOverrideMaxRetriesFromAnnotation() throws Throwable { + TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0, false); + interceptor.enqueueOutcome(new ConfigurableStatusException(ABORTED), "ok"); + + Object result = interceptor.invoke(invocationFor("ydbCustomRetry")); + + assertEquals("ok", result); + assertEquals(1, interceptor.retries()); + assertEquals(2, interceptor.allInvocations()); + } + + @Test + void shouldUseConfigMaxRetriesWhenAnnotationNotSet() throws Throwable { + TestableInterceptor interceptor = interceptorWithConfig(true, 2, 0, 0, 0, 0, false); + interceptor.enqueueOutcome(new ConfigurableStatusException(BAD_SESSION), "ok"); + + Object result = interceptor.invoke(invocationFor("defaultRetry")); + + assertEquals("ok", result); + assertEquals(1, interceptor.retries()); + assertEquals(2, interceptor.allInvocations()); + } + + @Test + void shouldExhaustAnnotatedMaxRetriesAndPropagate() { + TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0, false); + interceptor.enqueueOutcome(new ConfigurableStatusException(SESSION_BUSY), new ConfigurableStatusException(OVERLOADED), new ConfigurableStatusException(OVERLOADED)); + + ConfigurableStatusException exception = assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("ydbCustomRetry")) + ); + + assertEquals(OVERLOADED, exception.getStatus().getCode()); + assertEquals(2, interceptor.retries()); + assertEquals(3, interceptor.allInvocations()); + } + + @Test + void shouldUseAnnotatedMaxRetriesWhenLowerThanConfig() { + TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0, false); + interceptor.enqueueOutcome( + new ConfigurableStatusException(OVERLOADED), new ConfigurableStatusException(BAD_SESSION), new ConfigurableStatusException(OVERLOADED)); + + ConfigurableStatusException exception = assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("ydbCustomRetry")) + ); + + assertEquals(OVERLOADED, exception.getStatus().getCode()); + assertEquals(2, interceptor.retries()); + assertEquals(3, interceptor.allInvocations()); + } + + @Test + void shouldUseAnnotatedMaxRetriesWhenHigherThanConfig() throws Throwable { + TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0, false); + interceptor.enqueueOutcome( + new ConfigurableStatusException(BAD_SESSION), new ConfigurableStatusException(SESSION_BUSY), + new ConfigurableStatusException(ABORTED), new ConfigurableStatusException(OVERLOADED), + "ok"); + + Object result = interceptor.invoke(invocationFor("ydbRequiredRetry")); + + assertEquals("ok", result); + assertEquals(5, interceptor.allInvocations()); + } + + @Test + void shouldRetryDifferentStatusCodesAcrossRetries() throws Throwable { + TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0, false); + interceptor.enqueueOutcome( + new ConfigurableStatusException(ABORTED), + new ConfigurableStatusException(BAD_SESSION), + "ok"); + + Object result = interceptor.invoke(invocationFor("ydbRequiredRetry")); + + assertEquals("ok", result); + assertEquals(3, interceptor.allInvocations()); + } + + @Test + void shouldNotRetryClientCancelledWhenNotIdempotent() { + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, true); + interceptor.enqueueOutcome(new ConfigurableStatusException(CLIENT_CANCELLED), "ok"); + + ConfigurableStatusException exception = assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("ydbNonIdempotentRetry")) + ); + + assertEquals(CLIENT_CANCELLED, exception.getStatus().getCode()); + assertEquals(1, interceptor.allInvocations()); + } + + @Test + void shouldRetryClientCancelledWhenIdempotent() throws Throwable { + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, false); + interceptor.enqueueOutcome(new ConfigurableStatusException(CLIENT_CANCELLED), "ok"); + + Object result = interceptor.invoke(invocationFor("ydbIdempotentRetry")); + + assertEquals("ok", result); + assertEquals(2, interceptor.allInvocations()); + } + + @Test + void shouldNotRetryTransportUnavailableWhenNotIdempotent() { + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, true); + interceptor.enqueueOutcome(new ConfigurableStatusException(TRANSPORT_UNAVAILABLE), "ok"); + + ConfigurableStatusException exception = assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("ydbNonIdempotentRetry")) + ); + + assertEquals(TRANSPORT_UNAVAILABLE, exception.getStatus().getCode()); + assertEquals(1, interceptor.allInvocations()); + } + + @Test + void shouldRetryTransportUnavailableWhenIdempotent() throws Throwable { + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, false); + interceptor.enqueueOutcome(new ConfigurableStatusException(TRANSPORT_UNAVAILABLE), "ok"); + + Object result = interceptor.invoke(invocationFor("ydbIdempotentRetry")); + + assertEquals("ok", result); + assertEquals(2, interceptor.allInvocations()); + } + + @Test + void shouldRetryClientResourceExhaustedWhenNotIdempotent() throws Throwable { + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, false); + interceptor.enqueueOutcome(new ConfigurableStatusException(CLIENT_RESOURCE_EXHAUSTED), "ok"); + + Object result = interceptor.invoke(invocationFor("ydbNonIdempotentRetry")); + + assertEquals("ok", result); + assertEquals(2, interceptor.allInvocations()); + } + + @Test + void shouldRetryClientResourceExhaustedWhenIdempotent() throws Throwable { + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, false); + interceptor.enqueueOutcome(new ConfigurableStatusException(CLIENT_RESOURCE_EXHAUSTED), "ok"); + + Object result = interceptor.invoke(invocationFor("ydbIdempotentRetry")); + + assertEquals("ok", result); + assertEquals(2, interceptor.allInvocations()); + } + + @Test + void shouldNotRetryTimeoutWhenIdempotent() { + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, true); + interceptor.enqueueOutcome(new ConfigurableStatusException(TIMEOUT)); + + ConfigurableStatusException exception = assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("ydbIdempotentRetry")) + ); + + assertEquals(TIMEOUT, exception.getStatus().getCode()); + assertEquals(1, interceptor.allInvocations()); + } + + @Test + void shouldNotRetrySessionExpiredWhenNotIdempotent() { + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, true); + interceptor.enqueueOutcome(new ConfigurableStatusException(SESSION_EXPIRED)); + + ConfigurableStatusException exception = assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("ydbNonIdempotentRetry")) + ); + + assertEquals(SESSION_EXPIRED, exception.getStatus().getCode()); + assertEquals(1, interceptor.allInvocations()); + } + + @Test + void shouldRetryAlwaysRetryableCodesWhenIdempotent() throws Throwable { + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, true); + interceptor.enqueueOutcome(new ConfigurableStatusException(ABORTED), "ok"); + + Object result = interceptor.invoke(invocationFor("ydbIdempotentRetry")); + + assertEquals("ok", result); + assertEquals(2, interceptor.allInvocations()); + } + + @Test + void shouldRetryMixedStatusCodesWhenIdempotent() throws Throwable { + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, true); + interceptor.enqueueOutcome( + new ConfigurableStatusException(ABORTED), + new ConfigurableStatusException(UNDETERMINED), + new ConfigurableStatusException(CLIENT_CANCELLED), + "ok" + ); + + Object result = interceptor.invoke(invocationFor("ydbIdempotentRetry")); + + assertEquals("ok", result); + assertEquals(4, interceptor.allInvocations()); + } + + @Test + void shouldNotRetrySessionExpiredWhenIdempotent() { + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, true); + interceptor.enqueueOutcome(new ConfigurableStatusException(SESSION_EXPIRED)); + + ConfigurableStatusException exception = assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("ydbIdempotentRetry")) + ); + + assertEquals(SESSION_EXPIRED, exception.getStatus().getCode()); + assertEquals(1, interceptor.allInvocations()); + } + + @Test + void shouldStopAtIdempotentOnlyCodeWhenNotIdempotent() { + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, false); + interceptor.enqueueOutcome( + new ConfigurableStatusException(BAD_SESSION), + new ConfigurableStatusException(TIMEOUT) + ); + + ConfigurableStatusException exception = assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("ydbNonIdempotentRetry")) + ); + + assertEquals(TIMEOUT, exception.getStatus().getCode()); + assertEquals(2, interceptor.allInvocations()); + } + + @Test + void shouldNotReachDelayCalculatorForTimeoutWhenIdempotent() { + List delays = new ArrayList<>(); + TestableInterceptor interceptor = interceptorWithSleeper(true, 5, 100, 50, 1000, 500, true, delays::add); + interceptor.enqueueOutcome(new ConfigurableStatusException(TIMEOUT)); + + assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("ydbIdempotentRetry")) + ); + + assertEquals(1, interceptor.allInvocations()); + assertEquals(0, delays.size()); + } + + @Test + void shouldNotReachDelayCalculatorForSessionExpiredWhenIdempotent() { + List delays = new ArrayList<>(); + TestableInterceptor interceptor = interceptorWithSleeper(true, 5, 100, 50, 1000, 500, true, delays::add); + interceptor.enqueueOutcome(new ConfigurableStatusException(SESSION_EXPIRED)); + + assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("ydbIdempotentRetry")) + ); + + assertEquals(1, interceptor.allInvocations()); + assertEquals(0, delays.size()); + } + + @Test + void shouldUseFastBackoffForUndeterminedWhenIdempotent() throws Throwable { + List delays = new ArrayList<>(); + TestableInterceptor interceptor = interceptorWithSleeper(true, 5, 100, 50, 1000, 500, true, delays::add); + interceptor.enqueueOutcome(new ConfigurableStatusException(UNDETERMINED), "ok"); + + interceptor.invoke(invocationFor("ydbIdempotentRetry")); + + assertEquals(1, delays.size()); + assertTrue(delays.getFirst() >= 0); + } + + @Test + void shouldNotRetryWhenMethodDisablesRetry() { + TestableInterceptor interceptor = interceptorWithConfig(true, 3, 0, 0, 0, 0, false); + interceptor.enqueueOutcome(new ConfigurableStatusException(BAD_SESSION), "ok"); + + ConfigurableStatusException exception = assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("ydbRetryDisabled")) + ); + + assertEquals(BAD_SESSION, exception.getStatus().getCode()); + assertEquals(1, interceptor.allInvocations()); + } + + @Test + void shouldNotRetryWhenGlobalConfigDisablesRetryEvenIfMethodEnablesIt() { + TestableInterceptor interceptor = interceptorWithConfig(false, 3, 0, 0, 0, 0, false); + interceptor.enqueueOutcome(new ConfigurableStatusException(BAD_SESSION), "ok"); + + ConfigurableStatusException exception = assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("ydbRetryEnabled")) + ); + + assertEquals(BAD_SESSION, exception.getStatus().getCode()); + assertEquals(1, interceptor.allInvocations()); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/CombinedErrorIntegrationTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/CombinedErrorIntegrationTest.java new file mode 100644 index 00000000..c8898984 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/CombinedErrorIntegrationTest.java @@ -0,0 +1,88 @@ +package tech.ydb.retry.integration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import tech.ydb.core.StatusCode; +import tech.ydb.retry.integration.app.User; +import tech.ydb.retry.integration.app.UserApplication; +import tech.ydb.retry.integration.app.UserService; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(classes = UserApplication.class) +@ActiveProfiles({"enabled", "ydb"}) +class CombinedErrorIntegrationTest extends YdbDockerTest { + + @Autowired + private UserService userService; + + @BeforeEach + void cleanUp() { + DeterministicErrorChannel.configure(); + DeterministicErrorChannel.resetCounters(); + userService.deleteAll(); + } + + @Test + void shouldRetryWhenExecuteQueryFailsThenCommitFails() { + DeterministicErrorChannel.configure() + .onError("executeQuery", 1, StatusCode.ABORTED) + .onError("commitTransaction", 1, StatusCode.BAD_SESSION); + + userService.save(createUser(1L, "user1", "first1", "last1")); + + assertEquals(3, DeterministicErrorChannel.getCallCount("executeQuery")); + assertEquals(2, DeterministicErrorChannel.getCallCount("commitTransaction")); + assertNotNull(userService.findById(1L)); + } + + @Test + void shouldRetryWhenExecuteQueryFailsTwiceThenCommitFails() { + DeterministicErrorChannel.configure() + .onError("executeQuery", 1, StatusCode.ABORTED) + .onError("executeQuery", 2, StatusCode.BAD_SESSION) + .onError("commitTransaction", 1, StatusCode.SESSION_BUSY); + + userService.save(createUser(2L, "user2", "first2", "last2")); + + assertEquals(4, DeterministicErrorChannel.getCallCount("executeQuery")); + assertEquals(2, DeterministicErrorChannel.getCallCount("commitTransaction")); + assertNotNull(userService.findById(2L)); + } + + @Test + void shouldStopRetryWhenNonRetryableCommitFollowsRetryableExecuteQuery() { + DeterministicErrorChannel.configure() + .onError("executeQuery", 1, StatusCode.ABORTED) + .onError("commitTransaction", 1, StatusCode.SCHEME_ERROR); + + assertThrows(Exception.class, () -> userService.save(createUser(3L, "user3", "first3", "last3"))); + + assertEquals(2, DeterministicErrorChannel.getCallCount("executeQuery")); + assertEquals(1, DeterministicErrorChannel.getCallCount("commitTransaction")); + } + + @Test + void shouldRecoverFromMixedExecuteQueryAndCommitErrors() { + DeterministicErrorChannel.configure() + .onError("executeQuery", 1, StatusCode.ABORTED) + .onError("commitTransaction", 1, StatusCode.ABORTED) + .onError("commitTransaction", 2, StatusCode.BAD_SESSION); + + userService.save(createUser(4L, "user4", "first4", "last4")); + + assertTrue(DeterministicErrorChannel.getCallCount("executeQuery") >= 3); + assertTrue(DeterministicErrorChannel.getCallCount("commitTransaction") >= 3); + assertNotNull(userService.findById(4L)); + } + + private User createUser(Long id, String username, String firstname, String lastname) { + return new User(id, username, firstname, lastname); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/CommitTransactionRetryTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/CommitTransactionRetryTest.java new file mode 100644 index 00000000..31c4f2f8 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/CommitTransactionRetryTest.java @@ -0,0 +1,63 @@ +package tech.ydb.retry.integration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import tech.ydb.core.StatusCode; +import tech.ydb.retry.integration.app.User; +import tech.ydb.retry.integration.app.UserApplication; +import tech.ydb.retry.integration.app.UserService; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@SpringBootTest(classes = UserApplication.class) +@ActiveProfiles({"enabled", "ydb"}) +class CommitTransactionRetryTest extends YdbDockerTest { + + @Autowired + private UserService userService; + + @BeforeEach + void cleanUp() { + DeterministicErrorChannel.configure(); + DeterministicErrorChannel.resetCounters(); + userService.deleteAll(); + } + + @ParameterizedTest(name = "CommitTransaction") + @EnumSource(value = StatusCode.class, names = { + "ABORTED", "UNAVAILABLE", "OVERLOADED", "BAD_SESSION", + "SESSION_BUSY" + }) + void shouldRecoverFromRetryableCommitError(StatusCode code) { + DeterministicErrorChannel.configure().onError("commitTransaction", 1, code); + + userService.save(createUser(1L, "user1", "first1", "last1")); + + assertEquals(2, DeterministicErrorChannel.getCallCount("commitTransaction")); + assertNotNull(userService.findById(1L)); + } + + @ParameterizedTest(name = "CommitTransaction") + @EnumSource(value = StatusCode.class, names = { + "ABORTED", "UNAVAILABLE" + }) + void shouldRecoverFromMultipleCommitErrors(StatusCode code) { + DeterministicErrorChannel.configure() + .onError("commitTransaction", 1, code) + .onError("commitTransaction", 2, code); + + userService.save(createUser(2L, "user2", "first2", "last2")); + + assertEquals(3, DeterministicErrorChannel.getCallCount("commitTransaction")); + assertNotNull(userService.findById(2L)); + } + + private User createUser(Long id, String username, String firstname, String lastname) { + return new User(id, username, firstname, lastname); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ConcurrentRunner.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ConcurrentRunner.java new file mode 100644 index 00000000..22ee482b --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ConcurrentRunner.java @@ -0,0 +1,75 @@ +package tech.ydb.retry.integration; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.IntConsumer; + +class ConcurrentRunner { + + private final int threadCount; + private final ExecutorService executor; + private final CyclicBarrier barrier; + private final List> futures = new ArrayList<>(); + private final AtomicInteger successCount = new AtomicInteger(); + private final List errors = Collections.synchronizedList(new ArrayList<>()); + + private ConcurrentRunner(int threadCount) { + this.threadCount = threadCount; + this.executor = Executors.newFixedThreadPool(threadCount); + this.barrier = new CyclicBarrier(threadCount); + } + + static ConcurrentRunner with(int threadCount) { + return new ConcurrentRunner(threadCount); + } + + ConcurrentRunner execute(IntConsumer task) { + for (int i = 0; i < threadCount; i++) { + final int idx = i; + futures.add(executor.submit(() -> { + try { + barrier.await(); + task.accept(idx); + successCount.incrementAndGet(); + } catch (Throwable t) { + errors.add(t); + } + })); + } + return this; + } + + ConcurrentResult awaitCompletion(long timeout, TimeUnit unit) throws Exception { + for (Future f : futures) { + f.get(timeout, unit); + } + executor.shutdown(); + return new ConcurrentResult(successCount.get(), errors); + } + + record ConcurrentResult(int successCount, List errors) { + void assertAllSucceeded() { + if (!errors.isEmpty()) { + RuntimeException ex = new RuntimeException("Concurrent test had " + errors.size() + " failures"); + errors.forEach(ex::addSuppressed); + throw ex; + } + if (successCount == 0) { + throw new RuntimeException("No threads succeeded"); + } + } + + void assertSuccessCount(int expected) { + if (successCount != expected) { + throw new RuntimeException("Expected " + expected + " successes but got " + successCount); + } + } + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ConcurrentWriteIntegrationTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ConcurrentWriteIntegrationTest.java new file mode 100644 index 00000000..6cd24aee --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ConcurrentWriteIntegrationTest.java @@ -0,0 +1,84 @@ +package tech.ydb.retry.integration; + +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import tech.ydb.core.StatusCode; +import tech.ydb.retry.integration.app.User; +import tech.ydb.retry.integration.app.UserApplication; +import tech.ydb.retry.integration.app.UserService; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(classes = UserApplication.class) +@ActiveProfiles({"enabled", "ydb"}) +class ConcurrentWriteIntegrationTest extends YdbDockerTest { + + @Autowired + private UserService userService; + + @BeforeEach + void cleanUp() { + DeterministicErrorChannel.configure(); + DeterministicErrorChannel.resetCounters(); + userService.deleteAll(); + } + + @Test + void shouldInsertConcurrently() throws Exception { + ConcurrentRunner.with(10) + .execute(idx -> userService.save( + new User(1000L + idx, "user" + idx, "first" + idx, "last" + idx))) + .awaitCompletion(30, TimeUnit.SECONDS) + .assertAllSucceeded(); + + for (int i = 0; i < 10; i++) { + assertNotNull(userService.findById(1000L + i)); + } + } + + @Test + void shouldRetryOnConcurrentChannelErrors() throws Exception { + DeterministicErrorChannel.configure().onError("commitTransaction", 1, StatusCode.ABORTED); + + ConcurrentRunner.with(5) + .execute(idx -> userService.save( + new User(200L + idx, "user" + idx, "first" + idx, "last" + idx))) + .awaitCompletion(30, TimeUnit.SECONDS) + .assertAllSucceeded(); + } + + @Test + void shouldResolveConcurrentUpdateConflictsViaRetry() throws Exception { + userService.saveRaw(new User(1L, "user", "original", "original")); + + ConcurrentRunner.with(5) + .execute(idx -> userService.updateFirstname(1L, "new" + idx)) + .awaitCompletion(60, TimeUnit.SECONDS) + .assertAllSucceeded(); + + String firstname = userService.findById(1L).getFirstname(); + assertTrue(firstname.startsWith("new")); + assertTrue(DeterministicErrorChannel.getCallCount("commitTransaction") > 5); + } + + @Test + void shouldInsertConcurrentlyWithRetryErrors() throws Exception { + DeterministicErrorChannel.configure() + .onError("executeQuery", 1, StatusCode.ABORTED) + .onError("executeQuery", 2, StatusCode.BAD_SESSION); + + ConcurrentRunner.with(3) + .execute(idx -> userService.save( + new User(300L + idx, "user" + idx, "first" + idx, "last" + idx))) + .awaitCompletion(30, TimeUnit.SECONDS) + .assertAllSucceeded(); + + assertEquals(5, DeterministicErrorChannel.getCallCount("executeQuery")); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DeterministicErrorChannel.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DeterministicErrorChannel.java new file mode 100644 index 00000000..5b54812d --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DeterministicErrorChannel.java @@ -0,0 +1,162 @@ +package tech.ydb.retry.integration; + +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.ManagedChannelBuilder; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.Status; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Function; +import tech.ydb.core.StatusCode; +import tech.ydb.proto.StatusCodesProtos; +import tech.ydb.proto.query.YdbQuery; + +public class DeterministicErrorChannel implements Consumer>, ClientInterceptor { + + private record ErrorRule(String methodName, int callNumber, StatusCode code) { + boolean matches(String method, int callNum) { + return methodName.equals(method) && (callNumber == 0 || callNumber == callNum); + } + } + + private static final List rules = new CopyOnWriteArrayList<>(); + private static final ConcurrentHashMap counters = new ConcurrentHashMap<>(); + + private static final DeterministicErrorChannel INSTANCE = new DeterministicErrorChannel(); + + private static final Map> RESPONSE_BUILDERS = Map.of( + "ExecuteQuery", code -> YdbQuery.ExecuteQueryResponsePart.newBuilder().setStatus(code).build(), + "BeginTransaction", code -> YdbQuery.BeginTransactionResponse.newBuilder().setStatus(code).build(), + "CommitTransaction", code -> YdbQuery.CommitTransactionResponse.newBuilder().setStatus(code).build() + ); + + public DeterministicErrorChannel() { + loadFromSystemProperty(); + } + + public static DeterministicErrorChannel configure() { + rules.clear(); + counters.clear(); + return INSTANCE; + } + + public static void resetCounters() { + counters.clear(); + } + + public static int getCallCount(String method) { + String pascalName = Character.toUpperCase(method.charAt(0)) + method.substring(1); + AtomicInteger counter = counters.get(pascalName); + return counter != null ? counter.get() : 0; + } + + public DeterministicErrorChannel onError(String method, int callNumber, StatusCode code) { + addRule(method, callNumber, code); + return this; + } + + private static void addRule(String method, int callNumber, StatusCode code) { + String pascalName = Character.toUpperCase(method.charAt(0)) + method.substring(1); + toProto(code); + rules.add(new ErrorRule(pascalName, callNumber, code)); + } + + @Override + public void accept(ManagedChannelBuilder builder) { + builder.intercept(this); + } + + @Override + @SuppressWarnings("unchecked") + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + String fullMethodName = method.getFullMethodName(); + String shortName = fullMethodName.substring(fullMethodName.lastIndexOf('/') + 1); + + AtomicInteger counter = counters.computeIfAbsent(shortName, k -> new AtomicInteger()); + int callNum = counter.incrementAndGet(); + + for (ErrorRule rule : rules) { + if (rule.matches(shortName, callNum)) { + Function builderFn = RESPONSE_BUILDERS.get(shortName); + if (builderFn != null) { + StatusCodesProtos.StatusIds.StatusCode protoCode = toProto(rule.code()); + RespT errorMsg = (RespT) builderFn.apply(protoCode); + return new ErrorCall<>(errorMsg); + } + } + } + + return next.newCall(method, callOptions); + } + + private static StatusCodesProtos.StatusIds.StatusCode toProto(StatusCode code) { + try { + return StatusCodesProtos.StatusIds.StatusCode.valueOf(code.name()); + } catch (IllegalArgumentException ex) { + throw new IllegalArgumentException( + "Status " + code + " is not a YDB protobuf response status. ", + ex + ); + } + } + + private class ErrorCall extends ClientCall { + private final RespT errorMsg; + + ErrorCall(RespT errorMsg) { + this.errorMsg = errorMsg; + } + + @Override + public void start(Listener listener, Metadata headers) { + ForkJoinPool.commonPool().execute(() -> { + listener.onMessage(errorMsg); + listener.onClose(Status.OK, new Metadata()); + }); + } + + @Override + public void request(int numMessages) { + } + + @Override + public void cancel(String message, Throwable cause) { + } + + @Override + public void halfClose() { + } + + @Override + public void sendMessage(ReqT message) { + } + } + + private static void loadFromSystemProperty() { + String config = System.getProperty("deterministic.error.channel.rules"); + if (config == null || config.isBlank()) { + return; + } + rules.clear(); + counters.clear(); + for (String ruleStr : config.split(";")) { + String[] parts = ruleStr.trim().split(":"); + if (parts.length == 3) { + String method = parts[0].trim(); + int callNumber = Integer.parseInt(parts[1].trim()); + StatusCode code = StatusCode.valueOf(parts[2].trim()); + addRule(method, callNumber, code); + } + } + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DeterministicErrorChannelTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DeterministicErrorChannelTest.java new file mode 100644 index 00000000..47ab3950 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DeterministicErrorChannelTest.java @@ -0,0 +1,24 @@ +package tech.ydb.retry.integration; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static tech.ydb.core.StatusCode.ABORTED; +import static tech.ydb.core.StatusCode.CLIENT_CANCELLED; + +class DeterministicErrorChannelTest { + + @Test + void shouldAcceptProtobufResponseStatus() { + assertDoesNotThrow(() -> DeterministicErrorChannel.configure().onError("executeQuery", 1, ABORTED)); + } + + @Test + void shouldRejectClientSideStatusAtConfigurationTime() { + assertThrows( + IllegalArgumentException.class, + () -> DeterministicErrorChannel.configure().onError("executeQuery", 1, CLIENT_CANCELLED) + ); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DisabledRetryIntegrationTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DisabledRetryIntegrationTest.java new file mode 100644 index 00000000..9ac4cca3 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DisabledRetryIntegrationTest.java @@ -0,0 +1,52 @@ +package tech.ydb.retry.integration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import tech.ydb.core.StatusCode; +import tech.ydb.retry.integration.app.User; +import tech.ydb.retry.integration.app.UserApplication; +import tech.ydb.retry.integration.app.UserService; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest(classes = UserApplication.class) +@ActiveProfiles({"disabled", "ydb"}) +class DisabledRetryIntegrationTest extends YdbDockerTest { + + @Autowired + private UserService userService; + + @BeforeEach + void cleanUp() { + DeterministicErrorChannel.configure(); + userService.deleteAll(); + } + + @ParameterizedTest(name = "Retry disabled") + @EnumSource(value = StatusCode.class, names = { + "ABORTED", "UNAVAILABLE", "OVERLOADED" + }) + void shouldNotRetryWhenRetryDisabledExecuteQuery(StatusCode code) { + DeterministicErrorChannel.configure().onError("executeQuery", 1, code); + + assertThrows(Exception.class, () -> userService.saveRaw(createUser(1L, "user1", "first1", "last1"))); + } + + @ParameterizedTest(name = "Retry disabled") + @EnumSource(value = StatusCode.class, names = { + "ABORTED", "UNAVAILABLE", "OVERLOADED" + }) + void shouldNotRetryWhenRetryDisabledCommit(StatusCode code) { + DeterministicErrorChannel.configure().onError("commitTransaction", 1, code); + + assertThrows(Exception.class, () -> userService.saveRaw(createUser(2L, "user2", "first2", "last2"))); + } + + private User createUser(Long id, String username, String firstname, String lastname) { + return new User(id, username, firstname, lastname); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ExecuteQueryRetryIntegrationTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ExecuteQueryRetryIntegrationTest.java new file mode 100644 index 00000000..e654d4e0 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ExecuteQueryRetryIntegrationTest.java @@ -0,0 +1,88 @@ +package tech.ydb.retry.integration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import tech.ydb.core.StatusCode; +import tech.ydb.retry.integration.app.User; +import tech.ydb.retry.integration.app.UserApplication; +import tech.ydb.retry.integration.app.UserService; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest(classes = UserApplication.class) +@ActiveProfiles({"enabled", "ydb"}) +class ExecuteQueryRetryIntegrationTest extends YdbDockerTest { + + @Autowired + private UserService userService; + + @BeforeEach + void cleanUp() { + DeterministicErrorChannel.configure(); + DeterministicErrorChannel.resetCounters(); + userService.deleteAll(); + } + + @ParameterizedTest(name = "ExecuteQuery") + @EnumSource(value = StatusCode.class, names = { + "ABORTED", "UNAVAILABLE", "OVERLOADED", "BAD_SESSION", + "SESSION_BUSY" + }) + void shouldRecoverFromRetryableError(StatusCode code) { + DeterministicErrorChannel.configure().onError("executeQuery", 1, code); + + userService.save(createUser(1L, "user1", "first1", "last1")); + + assertEquals(2, DeterministicErrorChannel.getCallCount("executeQuery")); + assertNotNull(userService.findById(1L)); + } + + @ParameterizedTest(name = "ExecuteQuery") + @EnumSource(value = StatusCode.class, names = { + "ABORTED", "UNAVAILABLE", "OVERLOADED", "BAD_SESSION" + }) + void shouldRecoverFromMultipleRetryableErrors(StatusCode code) { + DeterministicErrorChannel.configure() + .onError("executeQuery", 1, code) + .onError("executeQuery", 2, code); + + userService.save(createUser(2L, "user2", "first2", "last2")); + + assertEquals(3, DeterministicErrorChannel.getCallCount("executeQuery")); + assertNotNull(userService.findById(2L)); + } + + @Test + void shouldRecoverFromMixedErrors() { + DeterministicErrorChannel.configure() + .onError("executeQuery", 1, StatusCode.ABORTED) + .onError("executeQuery", 2, StatusCode.BAD_SESSION); + + userService.save(createUser(3L, "user3", "first3", "last3")); + + assertEquals(3, DeterministicErrorChannel.getCallCount("executeQuery")); + assertNotNull(userService.findById(3L)); + } + + @ParameterizedTest(name = "ExecuteQuery") + @EnumSource(value = StatusCode.class, names = { + "SCHEME_ERROR", "GENERIC_ERROR", "PRECONDITION_FAILED" + }) + void shouldNotRetryNonRetryableError(StatusCode code) { + DeterministicErrorChannel.configure().onError("executeQuery", 1, code); + + assertThrows(Exception.class, () -> userService.save(createUser(4L, "user4", "first4", "last4"))); + assertEquals(1, DeterministicErrorChannel.getCallCount("executeQuery")); + } + + private User createUser(Long id, String username, String firstname, String lastname) { + return new User(id, username, firstname, lastname); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/HappyPathIntegrationTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/HappyPathIntegrationTest.java new file mode 100644 index 00000000..2e67aaee --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/HappyPathIntegrationTest.java @@ -0,0 +1,99 @@ +package tech.ydb.retry.integration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import tech.ydb.retry.integration.app.User; +import tech.ydb.retry.integration.app.UserApplication; +import tech.ydb.retry.integration.app.UserService; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +@SpringBootTest(classes = UserApplication.class) +@ActiveProfiles({"enabled", "ydb"}) +class HappyPathIntegrationTest extends YdbDockerTest { + + @Autowired + private UserService userService; + + @BeforeEach + void cleanUp() { + userService.deleteAll(); + } + + @Test + void shouldSaveAndFindById() { + User user = createUser(1L, "user1", "first", "last"); + userService.save(user); + + User found = userService.findById(1L); + assertNotNull(found); + assertEquals("user1", found.getUsername()); + assertEquals("first", found.getFirstname()); + assertEquals("last", found.getLastname()); + } + + @Test + void shouldSaveRaw() { + User user = createUser(2L, "user2", "first", "last"); + userService.saveRaw(user); + + User found = userService.findById(2L); + assertNotNull(found); + assertEquals("user2", found.getUsername()); + } + + @Test + void shouldSaveWithMaxRetries3() { + User user = createUser(3L, "user3", "first", "last"); + userService.saveWithMaxRetries3(user); + + User found = userService.findById(3L); + assertNotNull(found); + assertEquals("user3", found.getUsername()); + } + + @Test + void shouldSaveIdempotent() { + User user = createUser(4L, "user4", "first", "last"); + userService.saveIdempotent(user); + + User found = userService.findById(4L); + assertNotNull(found); + assertEquals("user4", found.getUsername()); + } + + @Test + void shouldUpdateFirstname() { + userService.save(createUser(5L, "user5", "original", "last")); + + userService.updateFirstname(5L, "updated"); + User found = userService.findById(5L); + assertNotNull(found); + assertEquals("updated", found.getFirstname()); + } + + @Test + void shouldDeleteAll() { + userService.save(createUser(6L, "user6", "first", "last")); + userService.save(createUser(7L, "user7", "first", "last")); + + userService.deleteAll(); + + assertNull(userService.findById(6L)); + assertNull(userService.findById(7L)); + } + + @Test + void shouldReturnNullForNonExistentUser() { + assertNull(userService.findById(999L)); + } + + private User createUser(Long id, String username, String firstname, String lastname) { + return new User(id, username, firstname, lastname); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/IdempotentRetryIntegrationTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/IdempotentRetryIntegrationTest.java new file mode 100644 index 00000000..78c77413 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/IdempotentRetryIntegrationTest.java @@ -0,0 +1,99 @@ +package tech.ydb.retry.integration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import tech.ydb.core.StatusCode; +import tech.ydb.retry.integration.app.User; +import tech.ydb.retry.integration.app.UserApplication; +import tech.ydb.retry.integration.app.UserService; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest(classes = UserApplication.class) +@ActiveProfiles({"enabled", "ydb"}) +class IdempotentRetryIntegrationTest extends YdbDockerTest { + + @Autowired + private UserService userService; + + @BeforeEach + void cleanUp() { + DeterministicErrorChannel.configure(); + DeterministicErrorChannel.resetCounters(); + userService.deleteAll(); + } + + @ParameterizedTest(name = "Idempotent executeQuery non-retryable") + @EnumSource(value = StatusCode.class, names = {"TIMEOUT", "SESSION_EXPIRED"}) + void shouldNotRetryTimeoutOrSessionExpiredWhenIdempotentExecuteQuery(StatusCode code) { + DeterministicErrorChannel.configure().onError("executeQuery", 1, code); + + assertThrows(Exception.class, () -> userService.saveIdempotent(createUser(1L, "user1", "first1", "last1"))); + + assertEquals(1, DeterministicErrorChannel.getCallCount("executeQuery")); + assertNull(userService.findById(1L)); + } + + @ParameterizedTest(name = "Non-idempotent executeQuery") + @EnumSource(value = StatusCode.class, names = {"TIMEOUT", "SESSION_EXPIRED", "UNDETERMINED"}) + void shouldNotRetryUndeterminedOrNonRetryableStatusWhenNotIdempotentExecuteQuery(StatusCode code) { + DeterministicErrorChannel.configure().onError("executeQuery", 1, code); + + assertThrows(Exception.class, () -> userService.save(createUser(2L, "user2", "first2", "last2"))); + assertEquals(1, DeterministicErrorChannel.getCallCount("executeQuery")); + assertNull(userService.findById(2L)); + } + + @ParameterizedTest(name = "Idempotent executeQuery") + @EnumSource(value = StatusCode.class, names = {"UNDETERMINED"}) + void shouldRetryUndeterminedWhenIdempotentExecuteQuery(StatusCode code) { + DeterministicErrorChannel.configure().onError("executeQuery", 1, code); + + userService.saveIdempotent(createUser(3L, "user3", "first3", "last3")); + + assertEquals(2, DeterministicErrorChannel.getCallCount("executeQuery")); + assertNotNull(userService.findById(3L)); + } + + @ParameterizedTest(name = "Idempotent commit non-retryable") + @EnumSource(value = StatusCode.class, names = {"TIMEOUT", "SESSION_EXPIRED"}) + void shouldNotRetryTimeoutOrSessionExpiredWhenIdempotentCommit(StatusCode code) { + DeterministicErrorChannel.configure().onError("commitTransaction", 1, code); + + assertThrows(Exception.class, () -> userService.saveIdempotent(createUser(4L, "user4", "first4", "last4"))); + + assertEquals(1, DeterministicErrorChannel.getCallCount("commitTransaction")); + } + + @ParameterizedTest(name = "Idempotent commit") + @EnumSource(value = StatusCode.class, names = {"UNDETERMINED"}) + void shouldRetryUndeterminedWhenIdempotentCommit(StatusCode code) { + DeterministicErrorChannel.configure().onError("commitTransaction", 1, code); + + userService.saveIdempotent(createUser(5L, "user5", "first5", "last5")); + + assertEquals(2, DeterministicErrorChannel.getCallCount("commitTransaction")); + assertNotNull(userService.findById(5L)); + } + + @ParameterizedTest(name = "Non-idempotent commit") + @EnumSource(value = StatusCode.class, names = {"UNDETERMINED"}) + void shouldNotRetryUndeterminedWhenNotIdempotentCommit(StatusCode code) { + DeterministicErrorChannel.configure().onError("commitTransaction", 1, code); + + assertThrows(Exception.class, () -> userService.save(createUser(6L, "user6", "first6", "last6"))); + + assertEquals(1, DeterministicErrorChannel.getCallCount("commitTransaction")); + } + + private User createUser(Long id, String username, String firstname, String lastname) { + return new User(id, username, firstname, lastname); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/IntegrationEnvironmentTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/IntegrationEnvironmentTest.java new file mode 100644 index 00000000..db135f79 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/IntegrationEnvironmentTest.java @@ -0,0 +1,22 @@ +package tech.ydb.retry.integration; + +import org.junit.jupiter.api.Test; +import org.testcontainers.DockerClientFactory; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +class IntegrationEnvironmentTest { + + @Test + void dockerShouldBeAvailableForIntegrationTests() { + try { + assertTrue( + DockerClientFactory.instance().isDockerAvailable(), + "Docker/Testcontainers must be available for integration tests" + ); + } catch (Throwable throwable) { + fail("Docker/Testcontainers must be available for integration tests", throwable); + } + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/MaxRetriesExhaustedTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/MaxRetriesExhaustedTest.java new file mode 100644 index 00000000..e86196d6 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/MaxRetriesExhaustedTest.java @@ -0,0 +1,59 @@ +package tech.ydb.retry.integration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import tech.ydb.core.StatusCode; +import tech.ydb.retry.integration.app.User; +import tech.ydb.retry.integration.app.UserApplication; +import tech.ydb.retry.integration.app.UserService; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest(classes = UserApplication.class) +@ActiveProfiles({"enabled", "ydb"}) +class MaxRetriesExhaustedTest extends YdbDockerTest { + + @Autowired + private UserService userService; + + @BeforeEach + void cleanUp() { + DeterministicErrorChannel.configure(); + DeterministicErrorChannel.resetCounters(); + userService.deleteAll(); + } + + @Test + void shouldExhaustMaxRetriesAndThrow() { + DeterministicErrorChannel.configure() + .onError("executeQuery", 1, StatusCode.ABORTED) + .onError("executeQuery", 2, StatusCode.ABORTED) + .onError("executeQuery", 3, StatusCode.ABORTED) + .onError("executeQuery", 4, StatusCode.ABORTED); + + assertThrows(Exception.class, + () -> userService.saveWithMaxRetries3(createUser(1L, "user1", "first1", "last1"))); + assertEquals(4, DeterministicErrorChannel.getCallCount("executeQuery")); + } + + @Test + void shouldSucceedOnLastAttemptMaxRetries() { + DeterministicErrorChannel.configure() + .onError("executeQuery", 1, StatusCode.ABORTED) + .onError("executeQuery", 2, StatusCode.ABORTED); + + userService.saveWithMaxRetries3(createUser(2L, "user2", "first2", "last2")); + + assertEquals(3, DeterministicErrorChannel.getCallCount("executeQuery")); + assertNotNull(userService.findById(2L)); + } + + private User createUser(Long id, String username, String firstname, String lastname) { + return new User(id, username, firstname, lastname); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/NonRetryableCommitIntegrationTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/NonRetryableCommitIntegrationTest.java new file mode 100644 index 00000000..6e21a355 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/NonRetryableCommitIntegrationTest.java @@ -0,0 +1,59 @@ +package tech.ydb.retry.integration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import tech.ydb.core.StatusCode; +import tech.ydb.retry.integration.app.User; +import tech.ydb.retry.integration.app.UserApplication; +import tech.ydb.retry.integration.app.UserService; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest(classes = UserApplication.class) +@ActiveProfiles({"enabled", "ydb"}) +class NonRetryableCommitIntegrationTest extends YdbDockerTest { + + @Autowired + private UserService userService; + + @BeforeEach + void cleanUp() { + DeterministicErrorChannel.configure(); + DeterministicErrorChannel.resetCounters(); + userService.deleteAll(); + } + + @ParameterizedTest(name = "NonRetryableCommit") + @EnumSource(value = StatusCode.class, names = { + "SCHEME_ERROR", "GENERIC_ERROR", "PRECONDITION_FAILED", "UNAUTHORIZED" + }) + void shouldNotRetryNonRetryableCommitError(StatusCode code) { + DeterministicErrorChannel.configure().onError("commitTransaction", 1, code); + + assertThrows(Exception.class, () -> userService.save(createUser(1L, "user1", "first1", "last1"))); + assertEquals(1, DeterministicErrorChannel.getCallCount("commitTransaction")); + assertNull(userService.findById(1L)); + } + + @ParameterizedTest(name = "NonRetryableCommit") + @EnumSource(value = StatusCode.class, names = { + "SCHEME_ERROR", "GENERIC_ERROR", "PRECONDITION_FAILED", "UNAUTHORIZED" + }) + void shouldNotRetryNonRetryableCommitErrorWithYdbTransactional(StatusCode code) { + DeterministicErrorChannel.configure().onError("commitTransaction", 1, code); + + assertThrows(Exception.class, () -> userService.saveWithMaxRetries3(createUser(2L, "user2", "first2", "last2"))); + assertEquals(1, DeterministicErrorChannel.getCallCount("commitTransaction")); + assertNull(userService.findById(2L)); + } + + private User createUser(Long id, String username, String firstname, String lastname) { + return new User(id, username, firstname, lastname); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/YdbDockerTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/YdbDockerTest.java new file mode 100644 index 00000000..3a35c963 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/YdbDockerTest.java @@ -0,0 +1,28 @@ +package tech.ydb.retry.integration; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import tech.ydb.test.junit5.YdbHelperExtension; + +public abstract class YdbDockerTest { + + @RegisterExtension + static final YdbHelperExtension ydb = new YdbHelperExtension(); + + @BeforeAll + static void resetErrorChannel() { + DeterministicErrorChannel.configure(); + } + + @DynamicPropertySource + static void propertySource(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", () -> + "jdbc:ydb:" + (ydb.useTls() ? "grpcs://" : "grpc://") + + ydb.endpoint() + ydb.database() + + "?channelInitializer=tech.ydb.retry.integration.DeterministicErrorChannel&" + + (ydb.authToken() != null ? "token=" + ydb.authToken() : "") + ); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/app/SimpleUserRepository.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/app/SimpleUserRepository.java new file mode 100644 index 00000000..f8847aab --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/app/SimpleUserRepository.java @@ -0,0 +1,13 @@ +package tech.ydb.retry.integration.app; + +import org.springframework.data.jdbc.repository.query.Modifying; +import org.springframework.data.jdbc.repository.query.Query; +import org.springframework.data.repository.ListCrudRepository; +import org.springframework.data.repository.query.Param; + +public interface SimpleUserRepository extends ListCrudRepository { + + @Modifying + @Query("UPDATE Users SET firstname = :newFirstname WHERE id = :id") + void updateFirstnameById(@Param("id") Long id, @Param("newFirstname") String newFirstname); +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/app/User.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/app/User.java new file mode 100644 index 00000000..61aa55e6 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/app/User.java @@ -0,0 +1,88 @@ +package tech.ydb.retry.integration.app; + +import java.util.Objects; +import org.springframework.data.annotation.Id; +import org.springframework.data.domain.Persistable; +import org.springframework.data.relational.core.mapping.Table; +import org.springframework.data.util.ProxyUtils; + +@Table(name = "Users") +public class User implements Persistable { + + @Id + private Long id; + + private String username; + + private String firstname; + + private String lastname; + + public User() { + } + + public User(Long id, String username, String firstname, String lastname) { + this.id = id; + this.username = username; + this.firstname = firstname; + this.lastname = lastname; + } + + @Override + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getFirstname() { + return firstname; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + public String getLastname() { + return lastname; + } + + public void setLastname(String lastname) { + this.lastname = lastname; + } + + @Override + public boolean isNew() { + return true; + } + + @Override + public boolean equals(Object other) { + if (other == null) { + return false; + } + if (this == other) { + return true; + } + if (getClass() != ProxyUtils.getUserClass(other)) { + return false; + } + User that = (User) other; + return Objects.equals(this.id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/app/UserApplication.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/app/UserApplication.java new file mode 100644 index 00000000..17e18699 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/app/UserApplication.java @@ -0,0 +1,13 @@ +package tech.ydb.retry.integration.app; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jdbc.repository.config.EnableJdbcRepositories; + +@EnableJdbcRepositories +@SpringBootApplication +public class UserApplication { + public static void main(String[] args) { + SpringApplication.run(UserApplication.class, args); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/app/UserService.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/app/UserService.java new file mode 100644 index 00000000..325fa2e5 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/app/UserService.java @@ -0,0 +1,51 @@ +package tech.ydb.retry.integration.app; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import tech.ydb.retry.YdbTransactional; + +@Service +public class UserService { + + private final SimpleUserRepository userRepository; + + public UserService(SimpleUserRepository userRepository) { + this.userRepository = userRepository; + } + + @Transactional + public void saveRaw(User user) { + userRepository.save(user); + } + + @YdbTransactional + public void save(User user) { + userRepository.save(user); + } + + @YdbTransactional(maxRetries = 3) + public void saveWithMaxRetries3(User user) { + userRepository.save(user); + } + + @YdbTransactional(idempotent = 1) + public void saveIdempotent(User user) { + userRepository.save(user); + } + + @YdbTransactional(maxRetries = 50, idempotent = 1) + public void updateFirstname(Long id, String firstname) { + userRepository.findById(id); + userRepository.updateFirstnameById(id, firstname); + } + + @Transactional(readOnly = true) + public User findById(Long id) { + return userRepository.findById(id).orElse(null); + } + + @Transactional + public void deleteAll() { + userRepository.deleteAll(); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/resources/application-disabled.properties b/spring-ydb/spring-ydb-retry/src/test/resources/application-disabled.properties new file mode 100644 index 00000000..55d84afa --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/resources/application-disabled.properties @@ -0,0 +1,7 @@ +spring.datasource.hikari.maximum-pool-size=5 +spring.datasource.hikari.connection-timeout=10000 + +ydb.transaction.retry.enabled=false + +logging.level.org.springframework.jdbc.core.JdbcTemplate=debug +logging.level.tech.ydb.retry=debug \ No newline at end of file diff --git a/spring-ydb/spring-ydb-retry/src/test/resources/application-enabled.properties b/spring-ydb/spring-ydb-retry/src/test/resources/application-enabled.properties new file mode 100644 index 00000000..5dc05df7 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/resources/application-enabled.properties @@ -0,0 +1,13 @@ +spring.datasource.hikari.maximum-pool-size=5 +spring.datasource.hikari.connection-timeout=10000 + +ydb.transaction.retry.enabled=true +ydb.transaction.retry.max-retries=5 +ydb.transaction.retry.slow-backoff-base-ms=50 +ydb.transaction.retry.fast-backoff-base-ms=5 +ydb.transaction.retry.slow-cap-backoff-ms=5000 +ydb.transaction.retry.fast-cap-backoff-ms=500 +ydb.transaction.retry.idempotent=false + +logging.level.org.springframework.jdbc.core.JdbcTemplate=debug +logging.level.tech.ydb.retry=debug \ No newline at end of file diff --git a/spring-ydb/spring-ydb-retry/src/test/resources/application-ydb.properties b/spring-ydb/spring-ydb-retry/src/test/resources/application-ydb.properties new file mode 100644 index 00000000..b036f9f5 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/resources/application-ydb.properties @@ -0,0 +1,2 @@ +spring.datasource.driver-class-name=tech.ydb.jdbc.YdbDriver +spring.datasource.url=jdbc:ydb:grpc://localhost:2136/local diff --git a/spring-ydb/spring-ydb-retry/src/test/resources/db/migration/V1__create_table.sql b/spring-ydb/spring-ydb-retry/src/test/resources/db/migration/V1__create_table.sql new file mode 100644 index 00000000..449db682 --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/resources/db/migration/V1__create_table.sql @@ -0,0 +1,9 @@ +CREATE TABLE Users +( + id Int64, + username Text, + firstname Text, + lastname Text, + PRIMARY KEY (id), + INDEX username_index GLOBAL ON (username) +) \ No newline at end of file From 5bd44d59602ba5c4d3515240f7cdca3d96d3e12f Mon Sep 17 00:00:00 2001 From: karambo3a Date: Fri, 8 May 2026 16:04:44 +0300 Subject: [PATCH 2/7] fix after review --- spring-ydb/spring-ydb-retry/README.md | 6 +- spring-ydb/spring-ydb-retry/pom.xml | 9 +- spring-ydb/spring-ydb-retry/slo/README.md | 90 +++++----- .../playground/chaos-aggressive/compose.yaml | 1 - .../slo/playground/chaos/compose.yaml | 1 - spring-ydb/spring-ydb-retry/slo/src/README.md | 66 ++++--- .../main/java/tech/ydb/slo/OtelConfig.java | 14 +- .../java/tech/ydb/slo/SloResultWriter.java | 65 ++++--- .../src/main/java/tech/ydb/slo/SloRunner.java | 168 +++++++++-------- .../main/java/tech/ydb/slo/SloService.java | 73 +++++--- .../src/main/java/tech/ydb/slo/SloStats.java | 60 ++++--- .../src/main/resources/application.properties | 1 - .../tech/ydb/retry/YdbDelayCalculator.java | 48 +++-- .../tech/ydb/retry/YdbRetryPolicyConfig.java | 85 ++++----- .../tech/ydb/retry/YdbRetryProperties.java | 13 +- .../YdbTransactionAutoConfiguration.java | 2 +- .../ydb/retry/YdbTransactionInterceptor.java | 36 ++-- .../YdbTransactionInterceptorFactory.java | 12 +- .../YdbTransactionInterceptorReplacer.java | 22 ++- .../java/tech/ydb/retry/YdbTransactional.java | 2 +- .../ydb/retry/InterceptorTestSupport.java | 51 ++++-- .../TransactionPropagationRetryTest.java | 15 +- .../retry/TransactionalDefaultRetryTest.java | 132 ++++++-------- .../ydb/retry/YdbDelayCalculatorTest.java | 67 +++++++ .../ydb/retry/YdbRetryPolicyConfigTest.java | 149 +++++++-------- .../tech/ydb/retry/YdbRetryPolicyTest.java | 75 ++++---- .../YdbTransactionInterceptorFactoryTest.java | 1 - ...YdbTransactionInterceptorReplacerTest.java | 46 +++-- .../YdbTransactionManagerResolutionTest.java | 60 ++++--- .../YdbTransactionalConfigOverrideTest.java | 170 ++++++++++-------- .../CombinedErrorIntegrationTest.java | 3 +- .../CommitTransactionRetryTest.java | 13 +- .../retry/integration/ConcurrentRunner.java | 23 +-- .../ConcurrentWriteIntegrationTest.java | 15 +- .../DeterministicErrorChannel.java | 37 ++-- .../DeterministicErrorChannelTest.java | 7 +- .../DisabledRetryIntegrationTest.java | 18 +- .../ExecuteQueryRetryIntegrationTest.java | 22 +-- .../integration/HappyPathIntegrationTest.java | 8 +- .../IdempotentRetryIntegrationTest.java | 41 +++-- .../IntegrationEnvironmentTest.java | 4 +- .../integration/MaxRetriesExhaustedTest.java | 3 +- .../NonRetryableCommitIntegrationTest.java | 19 +- .../ydb/retry/integration/YdbDockerTest.java | 25 ++- .../retry/integration/YdbIntegrationTest.java | 17 ++ .../retry/integration/app/UserService.java | 4 +- .../resources/application-enabled.properties | 1 - 47 files changed, 1013 insertions(+), 787 deletions(-) create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbDelayCalculatorTest.java create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/YdbIntegrationTest.java diff --git a/spring-ydb/spring-ydb-retry/README.md b/spring-ydb/spring-ydb-retry/README.md index 13e101e5..67141d45 100644 --- a/spring-ydb/spring-ydb-retry/README.md +++ b/spring-ydb/spring-ydb-retry/README.md @@ -54,7 +54,7 @@ Use `@YdbTransactional` as a drop-in replacement for `@Transactional` with addit retry parameters: ```java -@YdbTransactional(maxRetries = 5, idempotent = 1) +@YdbTransactional(maxRetries = 5, idempotent = true) public void save(User user) { // retried up to 5 times on YDB retryable errors } @@ -79,6 +79,6 @@ ydb.transaction.retry.slow-cap-backoff-ms=5000 ydb.transaction.retry.fast-backoff-base-ms=5 ydb.transaction.retry.fast-cap-backoff-ms=500 -# Enable idempotent retry for non-deterministic errors (default: false) -ydb.transaction.retry.idempotent=false ``` + +Idempotent-only retry is configured per method via `@YdbTransactional(idempotent = true)`. diff --git a/spring-ydb/spring-ydb-retry/pom.xml b/spring-ydb/spring-ydb-retry/pom.xml index f871f08f..9e64562d 100644 --- a/spring-ydb/spring-ydb-retry/pom.xml +++ b/spring-ydb/spring-ydb-retry/pom.xml @@ -161,6 +161,13 @@ maven-surefire-plugin 3.5.2 + + + junit.jupiter.execution.parallel.enabled = true + junit.jupiter.execution.parallel.mode.default = concurrent + junit.jupiter.execution.parallel.mode.classes.default = concurrent + + true @@ -168,4 +175,4 @@ - \ No newline at end of file + diff --git a/spring-ydb/spring-ydb-retry/slo/README.md b/spring-ydb/spring-ydb-retry/slo/README.md index 28e42248..3a49a295 100644 --- a/spring-ydb/spring-ydb-retry/slo/README.md +++ b/spring-ydb/spring-ydb-retry/slo/README.md @@ -1,25 +1,27 @@ # SLO Testing for YDB Spring Retry -SLO (Service Level Objectives) testing validates that the **spring-ydb-retry** library reduces visible application errors during YDB cluster node failures — restarts, shutdowns, network issues, and kill signals. +SLO (Service Level Objectives) testing validates that the **spring-ydb-retry** library reduces visible application +errors during YDB cluster node failures — restarts, shutdowns, network issues, and kill signals. ## How It Works Two identical Spring Boot applications run the same workload (read/write) against the same YDB cluster: -| Instance | Port | Retry | Description | -|---|---|---|---| -| `app-with-retry` | 8081 | **Enabled** (max 10 retries, idempotent=true) | Uses the same workload with global retry enabled | -| `app-no-retry` | 8082 | **Disabled** | Uses the same workload with `YDB_TRANSACTION_RETRY_ENABLED=false` | +| Instance | Port | Retry | Description | +|------------------|------|------------------------------|-------------------------------------------------------------------| +| `app-with-retry` | 8081 | **Enabled** (max 10 retries) | Uses the same workload with retry enabled | +| `app-no-retry` | 8082 | **Disabled** | Uses the same workload with `YDB_TRANSACTION_RETRY_ENABLED=false` | -A chaos script periodically stops, restarts, and kills random YDB nodes. The Grafana dashboard shows an error rate comparison, clearly demonstrating that retry significantly reduces visible application errors. +A chaos script periodically stops, restarts, and kills random YDB nodes. The Grafana dashboard shows an error rate +comparison, clearly demonstrating that retry significantly reduces visible application errors. ## Test Scenarios Two chaos levels are available: -| Scenario | Directory | Description | -|---|---|---| -| **chaos** | `playground/chaos/` | Baseline: stop/start, restart, SIGKILL of individual nodes | +| Scenario | Directory | Description | +|----------------------|--------------------------------|--------------------------------------------------------------------------------------------------| +| **chaos** | `playground/chaos/` | Baseline: stop/start, restart, SIGKILL of individual nodes | | **chaos-aggressive** | `playground/chaos-aggressive/` | Aggressive: pause/unpause, multi-node kill, rapid kill/start, triple kill + resource constraints | See [`playground/README.md`](playground/README.md) for details. @@ -46,7 +48,8 @@ docker compose up --build -d Navigate to **http://localhost:3000** (login: `admin` / `admin`). -The **"YDB Spring Retry SLO - Retry vs No-Retry Comparison"** dashboard is pre-loaded and auto-refreshes every 5 seconds. +The **"YDB Spring Retry SLO - Retry vs No-Retry Comparison"** dashboard is pre-loaded and auto-refreshes every 5 +seconds. ### 4. Stop @@ -62,53 +65,53 @@ docker compose down -v ## Services -| Service | URL | Description | -|---|---|---| -| Grafana | http://localhost:3000 | Metrics dashboard (admin/admin) | -| Prometheus | http://localhost:9090 | Metrics storage | -| YDB Monitoring | http://localhost:8765 | YDB cluster UI | -| YDB gRPC | grpc://localhost:2136 | YDB endpoint | -| App with retry metrics | internal `http://app-with-retry:9464/metrics` | Prometheus scrape target | -| App without retry metrics | internal `http://app-no-retry:9464/metrics` | Prometheus scrape target | +| Service | URL | Description | +|---------------------------|-----------------------------------------------|---------------------------------| +| Grafana | http://localhost:3000 | Metrics dashboard (admin/admin) | +| Prometheus | http://localhost:9090 | Metrics storage | +| YDB Monitoring | http://localhost:8765 | YDB cluster UI | +| YDB gRPC | grpc://localhost:2136 | YDB endpoint | +| App with retry metrics | internal `http://app-with-retry:9464/metrics` | Prometheus scrape target | +| App without retry metrics | internal `http://app-no-retry:9464/metrics` | Prometheus scrape target | -The app containers do not publish their internal Spring Boot or metrics ports to the host. Prometheus scrapes them over the Docker network at `:9464/metrics`. +The app containers do not publish their internal Spring Boot or metrics ports to the host. Prometheus scrapes them over +the Docker network at `:9464/metrics`. ## Metrics The SLO application exports Prometheus metrics via OpenTelemetry SDK: -| Metric | Type | Labels | Description | -|---|---|---|---| -| `slo_operations_total` | Counter | ref, operation_type, status, error_type | Total operations | +| Metric | Type | Labels | Description | +|----------------------------------|-----------|-----------------------------------------|-------------------| +| `slo_operations_total` | Counter | ref, operation_type, status, error_type | Total operations | | `slo_operation_duration_seconds` | Histogram | ref, operation_type, status, error_type | Operation latency | ### Labels -| Label | Values | Description | -|---|---|---| -| `ref` | `with-retry`, `no-retry` | Instance identifier | -| `operation_type` | `read`, `write` | Operation type | -| `status` | `success`, `failure` | Operation result | -| `error_type` | `none`, `UNAVAILABLE`, `TRANSPORT_UNAVAILABLE`, `OVERLOADED`, `BAD_SESSION`, ... | YDB status code or exception class name | +| Label | Values | Description | +|------------------|----------------------------------------------------------------------------------|-----------------------------------------| +| `ref` | `with-retry`, `no-retry` | Instance identifier | +| `operation_type` | `read`, `write` | Operation type | +| `status` | `success`, `failure` | Operation result | +| `error_type` | `none`, `UNAVAILABLE`, `TRANSPORT_UNAVAILABLE`, `OVERLOADED`, `BAD_SESSION`, ... | YDB status code or exception class name | ## Configuration Environment variables for the app containers: -| Variable | Default | Description | -|---|---|---| -| `SERVER_PORT` | 8080 | HTTP port | -| `SPRING_DATASOURCE_URL` | - | YDB JDBC URL | -| `YDB_TRANSACTION_RETRY_ENABLED` | true | Enable/disable retry | -| `YDB_TRANSACTION_RETRY_MAX_RETRIES` | 10 | Max retry attempts | -| `YDB_TRANSACTION_RETRY_IDEMPOTENT` | true | Treat operations as idempotent | -| `SLO_RUN_ID` | auto | Shared run identifier used for result folder name | -| `SLO_RESULTS_DIR` | `/app/results` in Docker | Root directory for saved run results | -| `REF` | unknown | Label for metrics (with-retry / no-retry) | -| `SLO_READ_RPS` | 100 | Target read RPS | -| `SLO_WRITE_RPS` | 100 | Target write RPS | -| `SLO_INITIAL_DATA` | 1000 | Initial rows to seed | -| `SLO_TIME` | 600 | Workload duration in seconds | +| Variable | Default | Description | +|-------------------------------------|--------------------------|---------------------------------------------------| +| `SERVER_PORT` | 8080 | HTTP port | +| `SPRING_DATASOURCE_URL` | - | YDB JDBC URL | +| `YDB_TRANSACTION_RETRY_ENABLED` | true | Enable/disable retry | +| `YDB_TRANSACTION_RETRY_MAX_RETRIES` | 10 | Max retry attempts | +| `SLO_RUN_ID` | auto | Shared run identifier used for result folder name | +| `SLO_RESULTS_DIR` | `/app/results` in Docker | Root directory for saved run results | +| `REF` | unknown | Label for metrics (with-retry / no-retry) | +| `SLO_READ_RPS` | 100 | Target read RPS | +| `SLO_WRITE_RPS` | 100 | Target write RPS | +| `SLO_INITIAL_DATA` | 1000 | Initial rows to seed | +| `SLO_TIME` | 600 | Workload duration in seconds | ## Saved Results @@ -119,4 +122,5 @@ results/ no-retry ``` -The `retry` file contains the final summary for `app-with-retry`, and `no-retry` contains the final summary for `app-no-retry`. +The `retry` file contains the final summary for `app-with-retry`, and `no-retry` contains the final summary for +`app-no-retry`. diff --git a/spring-ydb/spring-ydb-retry/slo/playground/chaos-aggressive/compose.yaml b/spring-ydb/spring-ydb-retry/slo/playground/chaos-aggressive/compose.yaml index 36d394cf..9501c198 100644 --- a/spring-ydb/spring-ydb-retry/slo/playground/chaos-aggressive/compose.yaml +++ b/spring-ydb/spring-ydb-retry/slo/playground/chaos-aggressive/compose.yaml @@ -291,7 +291,6 @@ services: SPRING_DATASOURCE_DRIVER_CLASS_NAME: tech.ydb.jdbc.YdbDriver YDB_TRANSACTION_RETRY_ENABLED: "true" YDB_TRANSACTION_RETRY_MAX_RETRIES: "10" - YDB_TRANSACTION_RETRY_IDEMPOTENT: "true" REF: with-retry SLO_RUN_ID: ${SLO_RUN_ID:-} SLO_RESULTS_DIR: /app/results diff --git a/spring-ydb/spring-ydb-retry/slo/playground/chaos/compose.yaml b/spring-ydb/spring-ydb-retry/slo/playground/chaos/compose.yaml index d8db77e3..b876ef0b 100644 --- a/spring-ydb/spring-ydb-retry/slo/playground/chaos/compose.yaml +++ b/spring-ydb/spring-ydb-retry/slo/playground/chaos/compose.yaml @@ -283,7 +283,6 @@ services: SPRING_DATASOURCE_DRIVER_CLASS_NAME: tech.ydb.jdbc.YdbDriver YDB_TRANSACTION_RETRY_ENABLED: "true" YDB_TRANSACTION_RETRY_MAX_RETRIES: "10" - YDB_TRANSACTION_RETRY_IDEMPOTENT: "true" REF: with-retry SLO_RUN_ID: ${SLO_RUN_ID:-} SLO_RESULTS_DIR: /app/results diff --git a/spring-ydb/spring-ydb-retry/slo/src/README.md b/spring-ydb/spring-ydb-retry/slo/src/README.md index 3bc93ee2..7f7bca6b 100644 --- a/spring-ydb/spring-ydb-retry/slo/src/README.md +++ b/spring-ydb/spring-ydb-retry/slo/src/README.md @@ -24,7 +24,6 @@ java -jar target/ydb-slo-workload-1.0.0-SNAPSHOT-exec.jar \ --spring.datasource.url=jdbc:ydb:grpc://localhost:2136/Root/testdb \ --ydb.transaction.retry.enabled=true \ --ydb.transaction.retry.max-retries=10 \ - --ydb.transaction.retry.idempotent=true \ --slo.ref=with-retry # Without retry @@ -72,25 +71,24 @@ All parameters are set via environment variables (or Spring Boot command-line ar ### Application -| Variable | Default | Description | -|---|---|---| -| `SERVER_PORT` | `8080` | HTTP port (Actuator endpoints) | -| `SPRING_DATASOURCE_URL` | `jdbc:ydb:grpc://localhost:2136/Root/testdb` | YDB JDBC URL | -| `YDB_TRANSACTION_RETRY_ENABLED` | `true` | Enable/disable retry | -| `YDB_TRANSACTION_RETRY_MAX_RETRIES` | `10` | Max retry attempts | -| `YDB_TRANSACTION_RETRY_IDEMPOTENT` | `true` | Treat operations as idempotent | -| `SLO_RUN_ID` | auto | Shared run identifier used for the result folder name | -| `SLO_RESULTS_DIR` | `results` | Root directory where per-run result folders are stored | +| Variable | Default | Description | +|-------------------------------------|----------------------------------------------|--------------------------------------------------------| +| `SERVER_PORT` | `8080` | HTTP port (Actuator endpoints) | +| `SPRING_DATASOURCE_URL` | `jdbc:ydb:grpc://localhost:2136/Root/testdb` | YDB JDBC URL | +| `YDB_TRANSACTION_RETRY_ENABLED` | `true` | Enable/disable retry | +| `YDB_TRANSACTION_RETRY_MAX_RETRIES` | `10` | Max retry attempts | +| `SLO_RUN_ID` | auto | Shared run identifier used for the result folder name | +| `SLO_RESULTS_DIR` | `results` | Root directory where per-run result folders are stored | ### Workload -| Variable | Default | Description | -|---|---|---| -| `SLO_READ_RPS` | `100` | Target read requests per second | -| `SLO_WRITE_RPS` | `100` | Target write requests per second | -| `SLO_INITIAL_DATA` | `1000` | Number of rows to pre-populate | -| `SLO_TIME` | `600` | Total run duration (seconds) | -| `REF` | `unknown` | Instance label for metrics (`with-retry` / `no-retry`) | +| Variable | Default | Description | +|--------------------|-----------|--------------------------------------------------------| +| `SLO_READ_RPS` | `100` | Target read requests per second | +| `SLO_WRITE_RPS` | `100` | Target write requests per second | +| `SLO_INITIAL_DATA` | `1000` | Number of rows to pre-populate | +| `SLO_TIME` | `600` | Total run duration (seconds) | +| `REF` | `unknown` | Instance label for metrics (`with-retry` / `no-retry`) | ## Saved Results @@ -105,19 +103,19 @@ The `retry` file is written by the `with-retry` instance, and `no-retry` is writ ## Collected Metrics (exposed via OpenTelemetry on :9464) -| Metric | Type | Labels | Description | -|---|---|---|---| -| `slo_operations_total` | Counter | ref, operation_type, status, error_type | Total number of operations | +| Metric | Type | Labels | Description | +|----------------------------------|-----------|-----------------------------------------|-----------------------------| +| `slo_operations_total` | Counter | ref, operation_type, status, error_type | Total number of operations | | `slo_operation_duration_seconds` | Histogram | ref, operation_type, status, error_type | Operation latency (seconds) | ### Labels -| Label | Values | Description | -|---|---|---| -| `ref` | `with-retry`, `no-retry` | Instance identifier | -| `operation_type` | `read`, `write` | Operation type | -| `status` | `success`, `failure` | Operation result | -| `error_type` | `none`, `UNAVAILABLE`, `TRANSPORT_UNAVAILABLE`, `OVERLOADED`, `BAD_SESSION`, … | YDB status code or exception class name | +| Label | Values | Description | +|------------------|--------------------------------------------------------------------------------|-----------------------------------------| +| `ref` | `with-retry`, `no-retry` | Instance identifier | +| `operation_type` | `read`, `write` | Operation type | +| `status` | `success`, `failure` | Operation result | +| `error_type` | `none`, `UNAVAILABLE`, `TRANSPORT_UNAVAILABLE`, `OVERLOADED`, `BAD_SESSION`, … | YDB status code or exception class name | ### Error Classification @@ -127,17 +125,17 @@ exception class name (e.g. `SqlTransientException`). ## Classes -| Class | Description | -|---|---| +| Class | Description | +|------------------|---------------------------------------------------------------------------------------------| | `SloApplication` | `@SpringBootApplication` entry point with `@EnableConfigurationProperties(SloConfig.class)` | -| `SloConfig` | `@ConfigurationProperties(prefix = "slo")` — binds workload parameters | -| `SloService` | `@YdbTransactional` service: `upsert()`, `upsert2()`, `select()`, `selectMaxId()` | -| `SloRunner` | `CommandLineRunner` — table creation, data seeding, load generation, metrics | -| `OtelConfig` | OpenTelemetry SDK bean — `PrometheusHttpServer` on port 9464 | - +| `SloConfig` | `@ConfigurationProperties(prefix = "slo")` — binds workload parameters | +| `SloService` | `@YdbTransactional` service: `upsert()`, `upsert2()`, `select()`, `selectMaxId()` | +| `SloRunner` | `CommandLineRunner` — table creation, data seeding, load generation, metrics | +| `OtelConfig` | OpenTelemetry SDK bean — `PrometheusHttpServer` on port 9464 | ## Grafana Dashboard Import the pre-built SLO dashboard from -[`playground/configs/grafana/provisioning/dashboards/slo.json`](../playground/configs/grafana/provisioning/dashboards/slo.json) +[ +`playground/configs/grafana/provisioning/dashboards/slo.json`](../playground/configs/grafana/provisioning/dashboards/slo.json) into your Grafana instance to visualize the collected metrics. diff --git a/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/OtelConfig.java b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/OtelConfig.java index fb5e8749..43f591c1 100644 --- a/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/OtelConfig.java +++ b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/OtelConfig.java @@ -13,16 +13,12 @@ public class OtelConfig { @Bean(destroyMethod = "close") public OpenTelemetrySdk openTelemetry() { - PrometheusHttpServer prometheusHttpServer = PrometheusHttpServer.builder() - .setPort(PROMETHEUS_PORT) - .build(); + PrometheusHttpServer prometheusHttpServer = + PrometheusHttpServer.builder().setPort(PROMETHEUS_PORT).build(); - SdkMeterProvider meterProvider = SdkMeterProvider.builder() - .registerMetricReader(prometheusHttpServer) - .build(); + SdkMeterProvider meterProvider = + SdkMeterProvider.builder().registerMetricReader(prometheusHttpServer).build(); - return OpenTelemetrySdk.builder() - .setMeterProvider(meterProvider) - .build(); + return OpenTelemetrySdk.builder().setMeterProvider(meterProvider).build(); } } diff --git a/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloResultWriter.java b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloResultWriter.java index 555cc9e3..58027767 100644 --- a/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloResultWriter.java +++ b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloResultWriter.java @@ -43,12 +43,13 @@ public String resolveRunId(SloConfig config, Instant startedAt) { try { Files.createDirectories(resultsRoot); Path currentRunIdFile = resultsRoot.resolve(CURRENT_RUN_ID_FILE); - try (FileChannel channel = FileChannel.open( - currentRunIdFile, - StandardOpenOption.CREATE, - StandardOpenOption.READ, - StandardOpenOption.WRITE - ); FileLock ignored = channel.lock()) { + try (FileChannel channel = + FileChannel.open( + currentRunIdFile, + StandardOpenOption.CREATE, + StandardOpenOption.READ, + StandardOpenOption.WRITE); + FileLock ignored = channel.lock()) { String existingRunId = readCurrentRunId(channel); if (!existingRunId.isBlank() && isReusableRun(resultsRoot.resolve(existingRunId))) { return existingRunId; @@ -63,7 +64,8 @@ public String resolveRunId(SloConfig config, Instant startedAt) { } } - public void writeSummary(SloConfig config, YdbRetryProperties retryProperties, RunSummary summary) { + public void writeSummary( + SloConfig config, YdbRetryProperties retryProperties, RunSummary summary) { Path runDirectory = resultsRoot(config).resolve(summary.runId()); Path resultFile = runDirectory.resolve(resultFileName(config.getRef())); @@ -74,14 +76,12 @@ public void writeSummary(SloConfig config, YdbRetryProperties retryProperties, R buildRunSummaryText(config, retryProperties, summary), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, - StandardOpenOption.WRITE - ); + StandardOpenOption.WRITE); log.info( "SLO run result file written: runId={}, ref={}, path={}", summary.runId(), config.getRef(), - resultFile.toAbsolutePath() - ); + resultFile.toAbsolutePath()); } catch (IOException exception) { throw new RuntimeException("Failed to write SLO run summary file", exception); } @@ -92,15 +92,18 @@ public Path resultsRoot(SloConfig config) { } private String generateRunId(Instant startedAt) { - String timestamp = DateTimeFormatter.ofPattern(RUN_ID_TIMESTAMP_PATTERN) - .withZone(ZoneOffset.UTC) - .format(startedAt); - return RUN_ID_PREFIX + timestamp + "-" + UUID.randomUUID().toString().substring(0, RUN_ID_RANDOM_SUFFIX_LENGTH); + String timestamp = + DateTimeFormatter.ofPattern(RUN_ID_TIMESTAMP_PATTERN) + .withZone(ZoneOffset.UTC) + .format(startedAt); + return RUN_ID_PREFIX + + timestamp + + "-" + + UUID.randomUUID().toString().substring(0, RUN_ID_RANDOM_SUFFIX_LENGTH); } - private String buildRunSummaryText(SloConfig config, - YdbRetryProperties retryProperties, - RunSummary summary) { + private String buildRunSummaryText( + SloConfig config, YdbRetryProperties retryProperties, RunSummary summary) { StringBuilder builder = new StringBuilder(); builder.append("runId: ").append(summary.runId()).append('\n'); builder.append("ref: ").append(config.getRef()).append('\n'); @@ -118,11 +121,18 @@ private String buildRunSummaryText(SloConfig config, builder.append('\n'); builder.append("retryEnabled: ").append(retryProperties.isEnabled()).append('\n'); builder.append("retryMaxRetries: ").append(retryProperties.getMaxRetries()).append('\n'); - builder.append("retryIdempotent: ").append(retryProperties.isIdempotent()).append('\n'); - builder.append("retrySlowBackoffBaseMs: ").append(retryProperties.getSlowBackoffBaseMs()).append('\n'); - builder.append("retryFastBackoffBaseMs: ").append(retryProperties.getFastBackoffBaseMs()).append('\n'); - builder.append("retrySlowCapBackoffMs: ").append(retryProperties.getSlowCapBackoffMs()).append('\n'); - builder.append("retryFastCapBackoffMs: ").append(retryProperties.getFastCapBackoffMs()).append('\n'); + builder.append("retrySlowBackoffBaseMs: ") + .append(retryProperties.getSlowBackoffBaseMs()) + .append('\n'); + builder.append("retryFastBackoffBaseMs: ") + .append(retryProperties.getFastBackoffBaseMs()) + .append('\n'); + builder.append("retrySlowCapBackoffMs: ") + .append(retryProperties.getSlowCapBackoffMs()) + .append('\n'); + builder.append("retryFastCapBackoffMs: ") + .append(retryProperties.getFastCapBackoffMs()) + .append('\n'); builder.append('\n'); builder.append("totalOperations: ").append(summary.totalOperations()).append('\n'); builder.append("totalSuccess: ").append(summary.totalSuccess()).append('\n'); @@ -147,8 +157,10 @@ private String buildRunSummaryText(SloConfig config, if (summary.errorCounts().isEmpty()) { builder.append(" none").append('\n'); } else { - summary.errorCounts().forEach((errorType, count) -> - builder.append(" ").append(errorType).append(": ").append(count).append('\n')); + summary.errorCounts() + .forEach( + (errorType, count) -> + builder.append(" ").append(errorType).append(": ").append(count).append('\n')); } return builder.toString(); } @@ -204,7 +216,6 @@ public record RunSummary( String writeP50, String writeP95, String writeP99, - Map errorCounts - ) { + Map errorCounts) { } } diff --git a/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloRunner.java b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloRunner.java index 6d809130..741b7a6b 100644 --- a/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloRunner.java +++ b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloRunner.java @@ -6,9 +6,19 @@ import io.opentelemetry.api.metrics.DoubleHistogram; import io.opentelemetry.api.metrics.LongCounter; import io.opentelemetry.api.metrics.Meter; +import java.security.MessageDigest; +import java.time.Instant; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.CommandLineRunner; @@ -18,17 +28,6 @@ import tech.ydb.jdbc.exception.YdbStatusable; import tech.ydb.retry.YdbRetryProperties; -import java.time.Instant; -import java.security.MessageDigest; -import java.time.LocalDateTime; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.ThreadLocalRandom; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - @Component public class SloRunner implements CommandLineRunner { @@ -36,10 +35,8 @@ public class SloRunner implements CommandLineRunner { private static final String OPERATIONS_METRIC_NAME = "slo.operations"; private static final String DURATION_METRIC_NAME = "slo.operation.duration.seconds"; private static final String DURATION_METRIC_UNIT = "s"; - private static final List DURATION_BUCKETS = List.of( - 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, - 1.0, 2.5, 5.0, 10.0, 30.0 - ); + private static final List DURATION_BUCKETS = + List.of(0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0); private static final String TABLE_NAME = "slo_test_table"; private static final String READ_OPERATION = "read"; @@ -65,9 +62,13 @@ public class SloRunner implements CommandLineRunner { private static final AttributeKey STATUS_KEY = AttributeKey.stringKey("status"); private static final AttributeKey ERROR_TYPE_KEY = AttributeKey.stringKey("error_type"); - public SloRunner(JdbcTemplate jdbcTemplate, SloService sloService, SloConfig config, - YdbRetryProperties retryProperties, SloResultWriter resultWriter, - OpenTelemetry openTelemetry) { + public SloRunner( + JdbcTemplate jdbcTemplate, + SloService sloService, + SloConfig config, + YdbRetryProperties retryProperties, + SloResultWriter resultWriter, + OpenTelemetry openTelemetry) { this.jdbcTemplate = jdbcTemplate; this.sloService = sloService; this.config = config; @@ -75,14 +76,16 @@ public SloRunner(JdbcTemplate jdbcTemplate, SloService sloService, SloConfig con this.resultWriter = resultWriter; Meter meter = openTelemetry.getMeter("slo"); - this.operationsCounter = meter.counterBuilder(OPERATIONS_METRIC_NAME) - .setDescription("Total number of SLO operations") - .build(); - this.durationHistogram = meter.histogramBuilder(DURATION_METRIC_NAME) - .setDescription("SLO operation latency") - .setUnit(DURATION_METRIC_UNIT) - .setExplicitBucketBoundariesAdvice(DURATION_BUCKETS) - .build(); + this.operationsCounter = + meter.counterBuilder(OPERATIONS_METRIC_NAME) + .setDescription("Total number of SLO operations") + .build(); + this.durationHistogram = + meter.histogramBuilder(DURATION_METRIC_NAME) + .setDescription("SLO operation latency") + .setUnit(DURATION_METRIC_UNIT) + .setExplicitBucketBoundariesAdvice(DURATION_BUCKETS) + .build(); } @Override @@ -102,20 +105,24 @@ private void createTable() { for (int attempt = 0; attempt < 10; attempt++) { try { jdbcTemplate.execute( - "CREATE TABLE " + TABLE_NAME + " (" + - "guid Text, " + - "id Int32, " + - "payload_str Text, " + - "payload_double Double, " + - "payload_timestamp Timestamp, " + - "PRIMARY KEY (guid, id)" + - ")" - ); + "CREATE TABLE " + + TABLE_NAME + + " (" + + "guid Text, " + + "id Int32, " + + "payload_str Text, " + + "payload_double Double, " + + "payload_timestamp Timestamp, " + + "PRIMARY KEY (guid, id)" + + ")"); log.info("Created table {}", TABLE_NAME); return; } catch (Exception e) { String msg = e.getMessage(); - if (msg != null && (msg.contains("already exists") || msg.contains("ALREADY_EXISTS") || msg.contains("path exist"))) { + if (msg != null + && (msg.contains("already exists") + || msg.contains("ALREADY_EXISTS") + || msg.contains("path exist"))) { log.info("Table slo_test_table already exists"); return; } @@ -154,8 +161,13 @@ private void seedData() { private void runWorkload(String runId) { String ref = config.getRef(); - log.info("Starting workload: runId={}, ref={}, readRps={}, writeRps={}, time={}s", - runId, ref, config.getReadRps(), config.getWriteRps(), config.getRunTimeSeconds()); + log.info( + "Starting workload: runId={}, ref={}, readRps={}, writeRps={}, time={}s", + runId, + ref, + config.getReadRps(), + config.getWriteRps(), + config.getRunTimeSeconds()); ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); ExecutorService workers = Executors.newFixedThreadPool(20); @@ -164,17 +176,27 @@ private void runWorkload(String runId) { int readsPerInterval = Math.max(1, config.getReadRps() / 10); int writesPerInterval = Math.max(1, config.getWriteRps() / 10); - ScheduledFuture readFuture = scheduler.scheduleAtFixedRate(() -> { - for (int i = 0; i < readsPerInterval; i++) { - workers.submit(() -> doRead(ref)); - } - }, 0, intervalMs, TimeUnit.MILLISECONDS); - - ScheduledFuture writeFuture = scheduler.scheduleAtFixedRate(() -> { - for (int i = 0; i < writesPerInterval; i++) { - workers.submit(() -> doWrite(ref)); - } - }, 0, intervalMs, TimeUnit.MILLISECONDS); + ScheduledFuture readFuture = + scheduler.scheduleAtFixedRate( + () -> { + for (int i = 0; i < readsPerInterval; i++) { + workers.submit(() -> doRead(ref)); + } + }, + 0, + intervalMs, + TimeUnit.MILLISECONDS); + + ScheduledFuture writeFuture = + scheduler.scheduleAtFixedRate( + () -> { + for (int i = 0; i < writesPerInterval; i++) { + workers.submit(() -> doWrite(ref)); + } + }, + 0, + intervalMs, + TimeUnit.MILLISECONDS); try { Thread.sleep(TimeUnit.SECONDS.toMillis(config.getRunTimeSeconds())); @@ -255,23 +277,25 @@ private Integer pickReadableId() { } private void incrementCounter(String ref, String operationType, String status, String errorType) { - Attributes attrs = Attributes.builder() - .put(REF_KEY, ref) - .put(OP_TYPE_KEY, operationType) - .put(STATUS_KEY, status) - .put(ERROR_TYPE_KEY, errorType) - .build(); + Attributes attrs = + Attributes.builder() + .put(REF_KEY, ref) + .put(OP_TYPE_KEY, operationType) + .put(STATUS_KEY, status) + .put(ERROR_TYPE_KEY, errorType) + .build(); operationsCounter.add(1, attrs); } - private void recordLatency(String ref, String operationType, String status, String errorType, - long durationNanos) { - Attributes attrs = Attributes.builder() - .put(REF_KEY, ref) - .put(OP_TYPE_KEY, operationType) - .put(STATUS_KEY, status) - .put(ERROR_TYPE_KEY, errorType) - .build(); + private void recordLatency( + String ref, String operationType, String status, String errorType, long durationNanos) { + Attributes attrs = + Attributes.builder() + .put(REF_KEY, ref) + .put(OP_TYPE_KEY, operationType) + .put(STATUS_KEY, status) + .put(ERROR_TYPE_KEY, errorType) + .build(); durationHistogram.record(durationNanos / 1_000_000_000.0, attrs); } @@ -322,27 +346,22 @@ static String randomString() { private void writeRunSummaryFile(String runId, Instant startedAt, Instant finishedAt) { resultWriter.writeSummary( - config, - retryProperties, - sloStats.calculate(runId, startedAt, finishedAt, sloStats) - ); + config, retryProperties, sloStats.calculate(runId, startedAt, finishedAt, sloStats)); } private void waitForPrometheusScrapes(String runId) { - log.info( - "Waiting {}s before shutdown to allow final Prometheus scrapes: runId={}", - 10, - runId - ); + log.info("Waiting {}s before shutdown to allow final Prometheus scrapes: runId={}", 10, runId); try { Thread.sleep(TimeUnit.SECONDS.toMillis(10)); } catch (InterruptedException interruptedException) { Thread.currentThread().interrupt(); - throw new RuntimeException("Interrupted while waiting for final Prometheus scrapes", interruptedException); + throw new RuntimeException( + "Interrupted while waiting for final Prometheus scrapes", interruptedException); } } - private static void awaitTermination(String name, ExecutorService executorService, long timeout, TimeUnit unit) { + private static void awaitTermination( + String name, ExecutorService executorService, long timeout, TimeUnit unit) { try { if (!executorService.awaitTermination(timeout, unit)) { throw new IllegalStateException(name + " did not terminate in time"); @@ -352,5 +371,4 @@ private static void awaitTermination(String name, ExecutorService executorServic throw new RuntimeException(name + " termination interrupted", interruptedException); } } - } diff --git a/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloService.java b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloService.java index c4618953..7d925001 100644 --- a/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloService.java +++ b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloService.java @@ -1,14 +1,13 @@ package tech.ydb.slo; +import java.sql.Timestamp; +import java.time.LocalDateTime; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import tech.ydb.retry.YdbTransactional; -import java.sql.Timestamp; -import java.time.LocalDateTime; - @Service public class SloService { @@ -23,36 +22,62 @@ public SloService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } - @YdbTransactional - public void upsert(String guid, int id, String payloadStr, double payloadDouble, - LocalDateTime payloadTimestamp) { - jdbcTemplate.update("UPSERT INTO " + TABLE_NAME + " (guid, id, payload_str, payload_double, payload_timestamp) VALUES (?, ?, ?, ?, ?)", - guid, id, payloadStr, payloadDouble, Timestamp.valueOf(payloadTimestamp) - ); + @YdbTransactional(idempotent = true) + public void upsert( + String guid, + int id, + String payloadStr, + double payloadDouble, + LocalDateTime payloadTimestamp) { + jdbcTemplate.update( + "UPSERT INTO " + + TABLE_NAME + + " (guid, id, payload_str, payload_double, payload_timestamp) VALUES (?, ?, ?, ?, ?)", + guid, + id, + payloadStr, + payloadDouble, + Timestamp.valueOf(payloadTimestamp)); } - @YdbTransactional - public void upsert2(String guid, int id, String payloadStr, double payloadDouble, - LocalDateTime payloadTimestamp) { - jdbcTemplate.update("UPSERT INTO " + TABLE_NAME + " (guid, id, payload_str, payload_double, payload_timestamp) VALUES (?, ?, ?, ?, ?)", - guid, id, payloadStr, payloadDouble, Timestamp.valueOf(payloadTimestamp) - ); + @YdbTransactional(idempotent = true) + public void upsert2( + String guid, + int id, + String payloadStr, + double payloadDouble, + LocalDateTime payloadTimestamp) { + jdbcTemplate.update( + "UPSERT INTO " + + TABLE_NAME + + " (guid, id, payload_str, payload_double, payload_timestamp) VALUES (?, ?, ?, ?, ?)", + guid, + id, + payloadStr, + payloadDouble, + Timestamp.valueOf(payloadTimestamp)); jdbcTemplate.update( - "UPSERT INTO " + TABLE_NAME + " (guid, id, payload_str, payload_double, payload_timestamp) VALUES (?, ?, ?, ?, ?)", - guid, id + SECOND_UPSERT_ID_OFFSET, payloadStr, - payloadDouble, Timestamp.valueOf(payloadTimestamp) - ); + "UPSERT INTO " + + TABLE_NAME + + " (guid, id, payload_str, payload_double, payload_timestamp) VALUES (?, ?, ?, ?, ?)", + guid, + id + SECOND_UPSERT_ID_OFFSET, + payloadStr, + payloadDouble, + Timestamp.valueOf(payloadTimestamp)); } - @YdbTransactional(readOnly = true) + @YdbTransactional(idempotent = true, readOnly = true) public String select(String guid, int id) { - return jdbcTemplate.queryForObject("SELECT payload_str FROM " + TABLE_NAME + " WHERE guid = ? AND id = ?", - String.class, guid, id - ); + return jdbcTemplate.queryForObject( + "SELECT payload_str FROM " + TABLE_NAME + " WHERE guid = ? AND id = ?", + String.class, + guid, + id); } - @YdbTransactional(readOnly = true) + @YdbTransactional(idempotent = true, readOnly = true) public int selectMaxId() { Integer result = jdbcTemplate.queryForObject(SELECT_MAX_ID_SQL, Integer.class); return result != null ? result : 0; diff --git a/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloStats.java b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloStats.java index 5285e221..ceac8a97 100644 --- a/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloStats.java +++ b/spring-ydb/spring-ydb-retry/slo/src/main/java/tech/ydb/slo/SloStats.java @@ -82,10 +82,11 @@ public List writeLatenciesSnapshot() { public Map errorCountsSnapshot() { return errorCounts.entrySet().stream() - .sorted(Map.Entry.comparingByValue( - Comparator.comparingLong(LongAdder::sum) - ).reversed()) - .collect(LinkedHashMap::new, + .sorted( + Map.Entry.comparingByValue(Comparator.comparingLong(LongAdder::sum)) + .reversed()) + .collect( + LinkedHashMap::new, (map, entry) -> map.put(entry.getKey(), entry.getValue().sum()), LinkedHashMap::putAll); } @@ -105,10 +106,8 @@ private static List snapshotLatencies(List latenciesNanos) { } } - public SloResultWriter.RunSummary calculate(String runId, - Instant startedAt, - Instant finishedAt, - SloStats runStats) { + public SloResultWriter.RunSummary calculate( + String runId, Instant startedAt, Instant finishedAt, SloStats runStats) { long readSuccess = runStats.getReadSuccess(); long readFailure = runStats.getReadFailure(); long writeSuccess = runStats.getWriteSuccess(); @@ -116,7 +115,11 @@ public SloResultWriter.RunSummary calculate(String runId, long totalSuccess = readSuccess + writeSuccess; long totalFailure = readFailure + writeFailure; long totalOperations = totalSuccess + totalFailure; - double failureRatePercent = totalOperations == 0 ? 0.0 : (double) totalFailure * PERCENT_FACTOR / totalOperations; + double failureRatePercent = + totalOperations == 0 ? 0.0 : (double) totalFailure * PERCENT_FACTOR / totalOperations; + List overallLatencies = sortedLatenciesSnapshot(runStats.overallLatenciesNanos); + List readLatencies = sortedLatenciesSnapshot(runStats.readLatenciesNanos); + List writeLatencies = sortedLatenciesSnapshot(runStats.writeLatenciesNanos); return new SloResultWriter.RunSummary( runId, @@ -130,26 +133,37 @@ public SloResultWriter.RunSummary calculate(String runId, readFailure, writeSuccess, writeFailure, - formatPercentileMillis(runStats.overallLatenciesSnapshot(), PERCENTILE_50), - formatPercentileMillis(runStats.overallLatenciesSnapshot(), PERCENTILE_95), - formatPercentileMillis(runStats.overallLatenciesSnapshot(), PERCENTILE_99), - formatPercentileMillis(runStats.readLatenciesSnapshot(), PERCENTILE_50), - formatPercentileMillis(runStats.readLatenciesSnapshot(), PERCENTILE_95), - formatPercentileMillis(runStats.readLatenciesSnapshot(), PERCENTILE_99), - formatPercentileMillis(runStats.writeLatenciesSnapshot(), PERCENTILE_50), - formatPercentileMillis(runStats.writeLatenciesSnapshot(), PERCENTILE_95), - formatPercentileMillis(runStats.writeLatenciesSnapshot(), PERCENTILE_99), - runStats.errorCountsSnapshot() - ); + formatPercentileMillis(overallLatencies, PERCENTILE_50), + formatPercentileMillis(overallLatencies, PERCENTILE_95), + formatPercentileMillis(overallLatencies, PERCENTILE_99), + formatPercentileMillis(readLatencies, PERCENTILE_50), + formatPercentileMillis(readLatencies, PERCENTILE_95), + formatPercentileMillis(readLatencies, PERCENTILE_99), + formatPercentileMillis(writeLatencies, PERCENTILE_50), + formatPercentileMillis(writeLatencies, PERCENTILE_95), + formatPercentileMillis(writeLatencies, PERCENTILE_99), + runStats.errorCountsSnapshot()); + } + + private static List sortedLatenciesSnapshot(List latenciesNanos) { + List snapshot = snapshotLatencies(latenciesNanos); + snapshot.sort(Long::compareTo); + return snapshot; } private static String formatPercentileMillis(List latenciesNanos, double percentile) { if (latenciesNanos.isEmpty()) { return EMPTY_PERCENTILE; } - latenciesNanos.sort(Long::compareTo); - int index = Math.min(latenciesNanos.size() - 1, (int) Math.ceil(percentile * latenciesNanos.size()) - 1); - double millis = latenciesNanos.get(index) / NANOS_IN_MILLISECOND; + double millis = percentileValue(latenciesNanos, percentile) / NANOS_IN_MILLISECOND; return String.format(Locale.ROOT, LATENCY_FORMAT, millis); } + + private static long percentileValue(List sortedLatenciesNanos, double percentile) { + int index = + Math.min( + sortedLatenciesNanos.size() - 1, + (int) Math.ceil(percentile * sortedLatenciesNanos.size()) - 1); + return sortedLatenciesNanos.get(index); + } } diff --git a/spring-ydb/spring-ydb-retry/slo/src/main/resources/application.properties b/spring-ydb/spring-ydb-retry/slo/src/main/resources/application.properties index 27f619bc..34fdbddb 100644 --- a/spring-ydb/spring-ydb-retry/slo/src/main/resources/application.properties +++ b/spring-ydb/spring-ydb-retry/slo/src/main/resources/application.properties @@ -6,7 +6,6 @@ spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.orm.jpa.Hibe ydb.transaction.retry.enabled=${YDB_TRANSACTION_RETRY_ENABLED:true} ydb.transaction.retry.max-retries=${YDB_TRANSACTION_RETRY_MAX_RETRIES:10} -ydb.transaction.retry.idempotent=${YDB_TRANSACTION_RETRY_IDEMPOTENT:true} slo.read-rps=${SLO_READ_RPS:100} slo.write-rps=${SLO_WRITE_RPS:100} diff --git a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbDelayCalculator.java b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbDelayCalculator.java index 3d0640c3..5bc7179c 100644 --- a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbDelayCalculator.java +++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbDelayCalculator.java @@ -4,33 +4,49 @@ import tech.ydb.core.StatusCode; public class YdbDelayCalculator { - public static long calculateDelay(@Nullable StatusCode statusCode, YdbRetryPolicyConfig retryConfig, int attempt) { + public static long calculateDelay( + @Nullable StatusCode statusCode, YdbRetryPolicyConfig retryConfig, int attempt) { if (statusCode == null) { return 0; } return switch (statusCode) { case BAD_SESSION, SESSION_BUSY -> 0; - case UNDETERMINED, ABORTED, CLIENT_CANCELLED, CLIENT_INTERNAL_ERROR -> - delayWithFullJitter(retryConfig.getFastBackoffBaseMs(), retryConfig.getFastCapBackoffMs(), - retryConfig.getFastPow(), attempt, retryConfig); - case UNAVAILABLE, TRANSPORT_UNAVAILABLE -> - delayWithEqualJitter(retryConfig.getFastBackoffBaseMs(), retryConfig.getFastCapBackoffMs(), - retryConfig.getFastPow(), attempt, retryConfig); - case OVERLOADED, CLIENT_RESOURCE_EXHAUSTED -> - delayWithEqualJitter(retryConfig.getSlowBackoffBaseMs(), retryConfig.getSlowCapBackoffMs(), - retryConfig.getSlowPow(), attempt, retryConfig); + case UNDETERMINED, ABORTED, CLIENT_CANCELLED, CLIENT_INTERNAL_ERROR -> delayWithFullJitter( + retryConfig.getFastBackoffBaseMs(), + retryConfig.getFastCapBackoffMs(), + retryConfig.getFastPow(), + attempt, + retryConfig); + case UNAVAILABLE, TRANSPORT_UNAVAILABLE -> delayWithEqualJitter( + retryConfig.getFastBackoffBaseMs(), + retryConfig.getFastCapBackoffMs(), + retryConfig.getFastPow(), + attempt, + retryConfig); + case OVERLOADED, CLIENT_RESOURCE_EXHAUSTED -> delayWithEqualJitter( + retryConfig.getSlowBackoffBaseMs(), + retryConfig.getSlowCapBackoffMs(), + retryConfig.getSlowPow(), + attempt, + retryConfig); default -> 0; }; } - private static long delayWithFullJitter(int baseMs, int capMs, int pow, int attempt, YdbRetryPolicyConfig retryConfig) { - long currentDelay = Math.min(baseMs * ((1L << Math.min(pow, attempt)) - 1), capMs); - return retryConfig.getJitter(currentDelay); + static long calculateBackoff(int baseMs, int capMs, int pow, int attempt) { + return Math.min((long) baseMs * (1L << Math.min(pow, attempt)), capMs); } - private static long delayWithEqualJitter(int baseMs, int capMs, int pow, int attempt, YdbRetryPolicyConfig retryConfig) { - long tmp = (long) baseMs * ((1L << Math.min(pow, attempt)) - 1) / 2; - return Math.min(tmp + retryConfig.getJitter(tmp), capMs); + private static long delayWithFullJitter( + int baseMs, int capMs, int pow, int attempt, YdbRetryPolicyConfig retryConfig) { + return retryConfig.getJitter(calculateBackoff(baseMs, capMs, pow, attempt)); + } + + private static long delayWithEqualJitter( + int baseMs, int capMs, int pow, int attempt, YdbRetryPolicyConfig retryConfig) { + long calculatedBackoff = calculateBackoff(baseMs, capMs, pow, attempt); + long temp = calculatedBackoff / 2; + return temp + calculatedBackoff % 2 + retryConfig.getJitter(temp); } } diff --git a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbRetryPolicyConfig.java b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbRetryPolicyConfig.java index 947319bf..2b9c8742 100644 --- a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbRetryPolicyConfig.java +++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbRetryPolicyConfig.java @@ -10,7 +10,6 @@ public final class YdbRetryPolicyConfig { public static final int DEFAULT_FAST_BACKOFF_BASE_MS = 5; public static final int DEFAULT_SLOW_CAP_BACKOFF_MS = 5_000; public static final int DEFAULT_FAST_CAP_BACKOFF_MS = 500; - public static final boolean DEFAULT_IDEMPOTENT = false; private final boolean enabled; private final int maxRetries; @@ -20,7 +19,6 @@ public final class YdbRetryPolicyConfig { private final int fastCapBackoffMs; private final int slowPow; private final int fastPow; - private final boolean idempotent; public YdbRetryPolicyConfig() { this( @@ -29,22 +27,23 @@ public YdbRetryPolicyConfig() { DEFAULT_SLOW_BACKOFF_BASE_MS, DEFAULT_FAST_BACKOFF_BASE_MS, DEFAULT_SLOW_CAP_BACKOFF_MS, - DEFAULT_FAST_CAP_BACKOFF_MS, - DEFAULT_IDEMPOTENT - ); + DEFAULT_FAST_CAP_BACKOFF_MS); } - public YdbRetryPolicyConfig(boolean enabled, int maxRetries, int slowBackoffBaseMs, int fastBackoffBaseMs, - int slowCapBackoffMs, int fastCapBackoffMs) { - this(enabled, maxRetries, slowBackoffBaseMs, fastBackoffBaseMs, slowCapBackoffMs, fastCapBackoffMs, false); - } - - public YdbRetryPolicyConfig(boolean enabled, int maxRetries, int slowBackoffBaseMs, int fastBackoffBaseMs, - int slowCapBackoffMs, int fastCapBackoffMs, boolean idempotent) { + public YdbRetryPolicyConfig( + boolean enabled, + int maxRetries, + int slowBackoffBaseMs, + int fastBackoffBaseMs, + int slowCapBackoffMs, + int fastCapBackoffMs) { if (maxRetries < 1) { throw new IllegalArgumentException("maxRetries must be >= 1"); } - if (slowBackoffBaseMs < 0 || fastBackoffBaseMs < 0 || slowCapBackoffMs < 0 || fastCapBackoffMs < 0) { + if (slowBackoffBaseMs < 0 + || fastBackoffBaseMs < 0 + || slowCapBackoffMs < 0 + || fastCapBackoffMs < 0) { throw new IllegalArgumentException("backoff values must be >= 0"); } this.enabled = enabled; @@ -55,14 +54,13 @@ public YdbRetryPolicyConfig(boolean enabled, int maxRetries, int slowBackoffBase this.maxRetries = maxRetries; this.slowPow = powerForCap(this.slowCapBackoffMs); this.fastPow = powerForCap(this.fastCapBackoffMs); - this.idempotent = idempotent; } public long getJitter(long bound) { if (bound <= 0) { return 0; } - return ThreadLocalRandom.current().nextLong(bound); + return ThreadLocalRandom.current().nextLong(bound + 1); } public boolean isEnabled() { @@ -97,46 +95,49 @@ public int getFastPow() { return fastPow; } - public boolean isIdempotent() { - return idempotent; - } - public YdbRetryPolicyConfig merge(@Nullable YdbTransactional transactionPolicy) { if (transactionPolicy == null) { return this; } return new YdbRetryPolicyConfig( enabled && transactionPolicy.enabled(), - checkCandidate("maxRetries", transactionPolicy.maxRetries(), maxRetries), - checkCandidate("slowBackoffBaseMs", transactionPolicy.slowBackoffBaseMs(), slowBackoffBaseMs), - checkCandidate("fastBackoffBaseMs", transactionPolicy.fastBackoffBaseMs(), fastBackoffBaseMs), - checkCandidate("slowCapBackoffMs", transactionPolicy.slowCapBackoffMs(), slowCapBackoffMs), - checkCandidate("fastCapBackoffMs", transactionPolicy.fastCapBackoffMs(), fastCapBackoffMs), - checkIdempotent(transactionPolicy.idempotent(), idempotent) - ); - } - - private static int checkCandidate(String name, int candidate, int fallback) throws IllegalArgumentException { + mergeMaxRetries(transactionPolicy.maxRetries(), maxRetries), + mergeNonNegativeInt( + "slowBackoffBaseMs", transactionPolicy.slowBackoffBaseMs(), slowBackoffBaseMs), + mergeNonNegativeInt( + "fastBackoffBaseMs", transactionPolicy.fastBackoffBaseMs(), fastBackoffBaseMs), + mergeNonNegativeInt( + "slowCapBackoffMs", transactionPolicy.slowCapBackoffMs(), slowCapBackoffMs), + mergeNonNegativeInt( + "fastCapBackoffMs", transactionPolicy.fastCapBackoffMs(), fastCapBackoffMs)); + } + + private static int mergeMaxRetries(int candidate, int fallback) { + return switch (candidate) { + case -1 -> fallback; + case 0 -> throw new IllegalArgumentException( + "maxRetries must not be 0; use enabled = false to disable retry"); + default -> { + if (candidate < -1) { + throw new IllegalArgumentException("maxRetries must be -1 or >= 1"); + } + yield candidate; + } + }; + } + + private static int mergeNonNegativeInt(String name, int candidate, int fallback) + throws IllegalArgumentException { if (candidate < -1) { throw new IllegalArgumentException(String.format("%s is invalid", name)); } return candidate == -1 ? fallback : candidate; } - private static boolean checkIdempotent(int candidate, boolean fallback) { - if (candidate == -1) { - return fallback; - } - if (candidate < -1 || candidate > 1) { - throw new IllegalArgumentException("idempotent must be -1, 0, or 1"); - } - return candidate == 1; - } - private static int powerForCap(int capMs) { - if (capMs <= 1) { - return 1; + if (capMs <= 0) { + return 0; } - return Math.max(1, (int) (Math.log(capMs) / Math.log(2))); + return Integer.SIZE - Integer.numberOfLeadingZeros(capMs); } } diff --git a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbRetryProperties.java b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbRetryProperties.java index 800f7dc1..6e8033ce 100644 --- a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbRetryProperties.java +++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbRetryProperties.java @@ -11,7 +11,6 @@ public class YdbRetryProperties { private int fastBackoffBaseMs = YdbRetryPolicyConfig.DEFAULT_FAST_BACKOFF_BASE_MS; private int slowCapBackoffMs = YdbRetryPolicyConfig.DEFAULT_SLOW_CAP_BACKOFF_MS; private int fastCapBackoffMs = YdbRetryPolicyConfig.DEFAULT_FAST_CAP_BACKOFF_MS; - private boolean idempotent = YdbRetryPolicyConfig.DEFAULT_IDEMPOTENT; public boolean isEnabled() { return enabled; @@ -61,14 +60,6 @@ public void setFastCapBackoffMs(int fastCapBackoffMs) { this.fastCapBackoffMs = fastCapBackoffMs; } - public boolean isIdempotent() { - return idempotent; - } - - public void setIdempotent(boolean idempotent) { - this.idempotent = idempotent; - } - public YdbRetryPolicyConfig toConfig() { return new YdbRetryPolicyConfig( enabled, @@ -76,8 +67,6 @@ public YdbRetryPolicyConfig toConfig() { slowBackoffBaseMs, fastBackoffBaseMs, slowCapBackoffMs, - fastCapBackoffMs, - idempotent - ); + fastCapBackoffMs); } } diff --git a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionAutoConfiguration.java b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionAutoConfiguration.java index a972b93c..e04e0ddc 100644 --- a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionAutoConfiguration.java +++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionAutoConfiguration.java @@ -25,4 +25,4 @@ public static YdbTransactionInterceptorReplacer ydbBeanDefinitionRegistryPostPro log.debug("creating YdbBeanDefinitionRegistryPostProcessor bean"); return new YdbTransactionInterceptorReplacer(); } -} \ No newline at end of file +} diff --git a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptor.java b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptor.java index 02cd789c..c9a233a3 100644 --- a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptor.java +++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptor.java @@ -25,8 +25,7 @@ public YdbTransactionInterceptor() { this(new YdbRetryPolicyConfig(), Thread::sleep); } - YdbTransactionInterceptor(YdbRetryPolicyConfig retryConfig, - BackoffSleeper backoffSleeper) { + YdbTransactionInterceptor(YdbRetryPolicyConfig retryConfig, BackoffSleeper backoffSleeper) { this.retryConfig = retryConfig; this.backoffSleeper = backoffSleeper; } @@ -45,27 +44,30 @@ public Object invoke(final MethodInvocation invocation) throws Throwable { if (isParticipatingInExistingTransaction(txAttr)) { log.debug( "YDB retry is disabled for method {} because it participates in an existing transaction", - invocation.getMethod().toGenericString() - ); + invocation.getMethod().toGenericString()); return this.invokeWithinTransaction(invocation.getMethod(), targetClass, createCallback(invocation)); } YdbTransactional ydbTransactional = resolveYdbTransactionAnnotation(invocation.getMethod(), targetClass); YdbRetryPolicyConfig retryConfig = this.retryConfig.merge(ydbTransactional); + boolean isIdempotent = ydbTransactional != null && ydbTransactional.idempotent(); if (!retryConfig.isEnabled()) { log.debug("YDB retry is disabled for method {}", invocation.getMethod().toGenericString()); return this.invokeWithinTransaction(invocation.getMethod(), targetClass, createCallback(invocation)); } - return invokeWithinTransactionWithRetryContext(invocation, targetClass, retryConfig); + return invokeWithinTransactionWithRetryContext(invocation, targetClass, retryConfig, isIdempotent); } @Nullable - private Object invokeWithinTransactionWithRetryContext(final MethodInvocation invocation, - @Nullable Class targetClass, - YdbRetryPolicyConfig retryConfig) throws Throwable { - for (int attempt = 1; attempt <= retryConfig.getMaxRetries() + 1; attempt++) { + private Object invokeWithinTransactionWithRetryContext( + final MethodInvocation invocation, + @Nullable Class targetClass, + YdbRetryPolicyConfig retryConfig, + boolean isIdempotent) + throws Throwable { + for (int attempt = 0; ; attempt++) { try { return this.invokeWithinTransaction(invocation.getMethod(), targetClass, createCallback(invocation)); } catch (Throwable ex) { @@ -73,17 +75,16 @@ private Object invokeWithinTransactionWithRetryContext(final MethodInvocation in throw ex; } StatusCode statusCode = extractStatusCode(ex); - if (!YdbRetryPolicy.shouldRetry(statusCode, retryConfig.isIdempotent())) { + if (!YdbRetryPolicy.shouldRetry(statusCode, isIdempotent)) { throw ex; } - if (attempt == retryConfig.getMaxRetries() + 1) { + if (attempt >= retryConfig.getMaxRetries()) { throw ex; } - long delay = YdbDelayCalculator.calculateDelay(statusCode, retryConfig, attempt - 1); + long delay = YdbDelayCalculator.calculateDelay(statusCode, retryConfig, attempt); sleep(delay, ex); } } - throw new IllegalStateException("retry loop finished unexpectedly"); } private void sleep(long delay, Throwable originalException) throws Throwable { @@ -108,9 +109,12 @@ private boolean isParticipatingInExistingTransaction(TransactionAttribute txAttr } @Nullable - private YdbTransactional resolveYdbTransactionAnnotation(Method method, @Nullable Class targetClass) { - Method specificMethod = targetClass != null ? AopUtils.getMostSpecificMethod(method, targetClass) : method; - YdbTransactional methodLevel = AnnotatedElementUtils.findMergedAnnotation(specificMethod, YdbTransactional.class); + private YdbTransactional resolveYdbTransactionAnnotation( + Method method, @Nullable Class targetClass) { + Method specificMethod = + targetClass != null ? AopUtils.getMostSpecificMethod(method, targetClass) : method; + YdbTransactional methodLevel = + AnnotatedElementUtils.findMergedAnnotation(specificMethod, YdbTransactional.class); if (methodLevel != null) { return methodLevel; } diff --git a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorFactory.java b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorFactory.java index 990d3b97..02d73dd0 100644 --- a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorFactory.java +++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorFactory.java @@ -9,7 +9,8 @@ import org.springframework.transaction.annotation.TransactionManagementConfigurer; import org.springframework.transaction.interceptor.TransactionAttributeSource; -public class YdbTransactionInterceptorFactory implements FactoryBean, BeanFactoryAware { +public class YdbTransactionInterceptorFactory + implements FactoryBean, BeanFactoryAware { private YdbRetryProperties retryProperties; private TransactionAttributeSource transactionAttributeSource; @@ -32,10 +33,7 @@ public void setBeanFactory(BeanFactory beanFactory) throws BeansException { @Override public YdbTransactionInterceptor getObject() { - YdbTransactionInterceptor interceptor = new YdbTransactionInterceptor( - retryProperties.toConfig(), - Thread::sleep - ); + YdbTransactionInterceptor interceptor = new YdbTransactionInterceptor(retryProperties.toConfig(), Thread::sleep); interceptor.setTransactionAttributeSource(transactionAttributeSource); if (beanFactory != null) { interceptor.setBeanFactory(beanFactory); @@ -55,9 +53,7 @@ private TransactionManager resolveTransactionManager() { return null; } - TransactionManagementConfigurer configurer = beanFactory - .getBeanProvider(TransactionManagementConfigurer.class) - .getIfAvailable(); + TransactionManagementConfigurer configurer = beanFactory.getBeanProvider(TransactionManagementConfigurer.class).getIfAvailable(); if (configurer == null) { return null; } diff --git a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorReplacer.java b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorReplacer.java index 316cdd18..13d88cfe 100644 --- a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorReplacer.java +++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorReplacer.java @@ -10,14 +10,16 @@ import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; import org.springframework.core.Ordered; -public class YdbTransactionInterceptorReplacer implements BeanDefinitionRegistryPostProcessor, Ordered { +public class YdbTransactionInterceptorReplacer + implements BeanDefinitionRegistryPostProcessor, Ordered { private static final Logger log = LoggerFactory.getLogger(YdbTransactionInterceptorReplacer.class); private static final String TRANSACTION_INTERCEPTOR_BEAN_NAME = "transactionInterceptor"; @Override - public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException { + public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) + throws BeansException { if (!registry.containsBeanDefinition(TRANSACTION_INTERCEPTOR_BEAN_NAME)) { log.debug("BeanDefinition '{}' not found", TRANSACTION_INTERCEPTOR_BEAN_NAME); return; @@ -26,7 +28,9 @@ public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) t BeanDefinition existingBd = registry.getBeanDefinition(TRANSACTION_INTERCEPTOR_BEAN_NAME); if (YdbTransactionInterceptorFactory.class.getName().equals(existingBd.getBeanClassName())) { - log.debug("BeanDefinition '{}' is already YdbTransactionInterceptorFactory", TRANSACTION_INTERCEPTOR_BEAN_NAME); + log.debug( + "BeanDefinition '{}' is already YdbTransactionInterceptorFactory", + TRANSACTION_INTERCEPTOR_BEAN_NAME); return; } @@ -35,14 +39,16 @@ public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) t registry.removeBeanDefinition(TRANSACTION_INTERCEPTOR_BEAN_NAME); registry.registerBeanDefinition(TRANSACTION_INTERCEPTOR_BEAN_NAME, newBd); - log.info("registered YdbTransactionInterceptorFactory as bean '{}'", TRANSACTION_INTERCEPTOR_BEAN_NAME); + log.info( + "registered YdbTransactionInterceptorFactory as bean '{}'", + TRANSACTION_INTERCEPTOR_BEAN_NAME); } private AbstractBeanDefinition buildYdbInterceptorBeanDefinition(BeanDefinition existingBd) { - AbstractBeanDefinition newBd = BeanDefinitionBuilder - .genericBeanDefinition(YdbTransactionInterceptorFactory.class) - .setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE) - .getBeanDefinition(); + AbstractBeanDefinition newBd = + BeanDefinitionBuilder.genericBeanDefinition(YdbTransactionInterceptorFactory.class) + .setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE) + .getBeanDefinition(); copyBeanDefinitionMetadata(existingBd, newBd); return newBd; diff --git a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactional.java b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactional.java index b3047be9..009e6efb 100644 --- a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactional.java +++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactional.java @@ -67,5 +67,5 @@ int fastCapBackoffMs() default -1; - int idempotent() default -1; + boolean idempotent() default false; } diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/InterceptorTestSupport.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/InterceptorTestSupport.java index 3c842801..8b109043 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/InterceptorTestSupport.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/InterceptorTestSupport.java @@ -22,19 +22,25 @@ void cleanupTransactionContext() { TransactionSynchronizationManager.clear(); } - static TestableInterceptor interceptorWithConfig(boolean enabled, int maxRetries, int slowBase, int fastBase, - int slowCap, int fastCap, boolean isIdempotent) { - return interceptorWithSleeper(enabled, maxRetries, slowBase, fastBase, slowCap, fastCap, isIdempotent, delay -> { - }); + static TestableInterceptor interceptorWithConfig( + boolean enabled, int maxRetries, int slowBase, int fastBase, int slowCap, int fastCap) { + return interceptorWithSleeper( + enabled, maxRetries, slowBase, fastBase, slowCap, fastCap, delay -> { + }); } - static TestableInterceptor interceptorWithSleeper(boolean enabled, int maxRetries, int slowBase, int fastBase, - int slowCap, int fastCap, boolean isIdempotent, - BackoffSleeper sleeper) { - TestableInterceptor interceptor = new TestableInterceptor( - new YdbRetryPolicyConfig(enabled, maxRetries, slowBase, fastBase, slowCap, fastCap, isIdempotent), - sleeper - ); + static TestableInterceptor interceptorWithSleeper( + boolean enabled, + int maxRetries, + int slowBase, + int fastBase, + int slowCap, + int fastCap, + BackoffSleeper sleeper) { + TestableInterceptor interceptor = + new TestableInterceptor( + new YdbRetryPolicyConfig(enabled, maxRetries, slowBase, fastBase, slowCap, fastCap), + sleeper); interceptor.setTransactionAttributeSource(new AnnotationTransactionAttributeSource()); return interceptor; } @@ -71,8 +77,7 @@ static final class TestableInterceptor extends YdbTransactionInterceptor { private final Deque outcomes = new ArrayDeque<>(); private final AtomicInteger attempts = new AtomicInteger(); - TestableInterceptor(YdbRetryPolicyConfig retryConfig, - BackoffSleeper backoffSleeper) { + TestableInterceptor(YdbRetryPolicyConfig retryConfig, BackoffSleeper backoffSleeper) { super(retryConfig, backoffSleeper); } @@ -91,8 +96,8 @@ int retries() { } @Override - protected Object invokeWithinTransaction(Method method, Class targetClass, InvocationCallback invocation) - throws Throwable { + protected Object invokeWithinTransaction( + Method method, Class targetClass, InvocationCallback invocation) throws Throwable { attempts.incrementAndGet(); Object result = outcomes.removeFirst(); if (result instanceof Throwable throwable) { @@ -160,7 +165,12 @@ public String ydbTimeoutString() { return "ok"; } - @YdbTransactional(maxRetries = 100, slowBackoffBaseMs = 200, fastBackoffBaseMs = 10, slowCapBackoffMs = 10000, fastCapBackoffMs = 12) + @YdbTransactional( + maxRetries = 100, + slowBackoffBaseMs = 200, + fastBackoffBaseMs = 10, + slowCapBackoffMs = 10000, + fastCapBackoffMs = 12) public String ydbNewTransactionSettings() { return "ok"; } @@ -170,12 +180,17 @@ public String ydbNegativeMaxRetries() { return "ok"; } - @YdbTransactional(maxRetries = 5, idempotent = 1) + @YdbTransactional(maxRetries = 0) + public String ydbZeroMaxRetries() { + return "ok"; + } + + @YdbTransactional(maxRetries = 5, idempotent = true) public String ydbIdempotentRetry() { return "ok"; } - @YdbTransactional(maxRetries = 3, idempotent = 0) + @YdbTransactional(maxRetries = 3) public String ydbNonIdempotentRetry() { return "ok"; } diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/TransactionPropagationRetryTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/TransactionPropagationRetryTest.java index 39cd0a6d..0029778c 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/TransactionPropagationRetryTest.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/TransactionPropagationRetryTest.java @@ -14,13 +14,11 @@ class TransactionPropagationRetryTest extends InterceptorTestSupport { void shouldDisableRetryWhenParticipatingInOuterTransaction() { TransactionSynchronizationManager.setActualTransactionActive(true); - TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0); interceptor.enqueueOutcome(new IllegalStateException("no retry expected")); assertThrows( - IllegalStateException.class, - () -> interceptor.invoke(invocationFor("ydbRequiredRetry")) - ); + IllegalStateException.class, () -> interceptor.invoke(invocationFor("ydbRequiredRetry"))); assertEquals(1, interceptor.allInvocations()); } @@ -28,7 +26,7 @@ void shouldDisableRetryWhenParticipatingInOuterTransaction() { void shouldRetryWithRequiresNewInsideOuterTransaction() throws Throwable { TransactionSynchronizationManager.setActualTransactionActive(true); - TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0); interceptor.enqueueOutcome(new ConfigurableStatusException(BAD_SESSION), "ok"); Object result = interceptor.invoke(invocationFor("ydbRequiresNewRetry")); @@ -41,13 +39,12 @@ void shouldRetryWithRequiresNewInsideOuterTransaction() throws Throwable { void shouldDisableRetryWithNestedPropagationInsideOuterTransaction() { TransactionSynchronizationManager.setActualTransactionActive(true); - TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0); interceptor.enqueueOutcome(new ConfigurableStatusException(ABORTED)); assertThrows( ConfigurableStatusException.class, - () -> interceptor.invoke(invocationFor("ydbNestedRetry")) - ); + () -> interceptor.invoke(invocationFor("ydbNestedRetry"))); assertEquals(1, interceptor.allInvocations()); } @@ -55,7 +52,7 @@ void shouldDisableRetryWithNestedPropagationInsideOuterTransaction() { void shouldRetryWithNotSupportedPropagationInsideOuterTransaction() throws Throwable { TransactionSynchronizationManager.setActualTransactionActive(true); - TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0); interceptor.enqueueOutcome(new ConfigurableStatusException(BAD_SESSION), "ok"); Object result = interceptor.invoke(invocationFor("ydbNotSupportedRetry")); diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/TransactionalDefaultRetryTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/TransactionalDefaultRetryTest.java index 79f8fb6b..d85598d3 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/TransactionalDefaultRetryTest.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/TransactionalDefaultRetryTest.java @@ -18,7 +18,7 @@ class TransactionalDefaultRetryTest extends InterceptorTestSupport { @Test void shouldRetryWithDefaultConfigUntilSuccess() throws Throwable { - TestableInterceptor interceptor = interceptorWithConfig(true, 3, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(true, 3, 0, 0, 0, 0); interceptor.enqueueOutcome(new ConfigurableStatusException(BAD_SESSION), "ok"); Object result = interceptor.invoke(invocationFor("regularTx")); @@ -30,13 +30,14 @@ void shouldRetryWithDefaultConfigUntilSuccess() throws Throwable { @Test void shouldExhaustDefaultMaxRetriesAndPropagateLastException() { - TestableInterceptor interceptor = interceptorWithConfig(true, 2, 0, 0, 0, 0, false); - interceptor.enqueueOutcome(new ConfigurableStatusException(BAD_SESSION), new ConfigurableStatusException(ABORTED), new ConfigurableStatusException(ABORTED)); + TestableInterceptor interceptor = interceptorWithConfig(true, 2, 0, 0, 0, 0); + interceptor.enqueueOutcome( + new ConfigurableStatusException(BAD_SESSION), + new ConfigurableStatusException(ABORTED), + new ConfigurableStatusException(ABORTED)); assertThrows( - ConfigurableStatusException.class, - () -> interceptor.invoke(invocationFor("regularTx")) - ); + ConfigurableStatusException.class, () -> interceptor.invoke(invocationFor("regularTx"))); assertEquals(2, interceptor.retries()); assertEquals(3, interceptor.allInvocations()); @@ -44,13 +45,13 @@ void shouldExhaustDefaultMaxRetriesAndPropagateLastException() { @Test void shouldPropagateNonRetryableExceptionImmediately() { - TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0); interceptor.enqueueOutcome(new ConfigurableStatusException(UNAUTHORIZED)); - ConfigurableStatusException exception = assertThrows( - ConfigurableStatusException.class, - () -> interceptor.invoke(invocationFor("regularTx")) - ); + ConfigurableStatusException exception = + assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("regularTx"))); assertEquals(UNAUTHORIZED, exception.getStatus().getCode()); assertEquals(0, interceptor.retries()); @@ -59,13 +60,12 @@ void shouldPropagateNonRetryableExceptionImmediately() { @Test void shouldNotRetryNonYdbRuntimeException() { - TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0); interceptor.enqueueOutcome(new IllegalStateException("not ydb")); - IllegalStateException exception = assertThrows( - IllegalStateException.class, - () -> interceptor.invoke(invocationFor("regularTx")) - ); + IllegalStateException exception = + assertThrows( + IllegalStateException.class, () -> interceptor.invoke(invocationFor("regularTx"))); assertEquals("not ydb", exception.getMessage()); assertEquals(0, interceptor.retries()); @@ -74,19 +74,16 @@ void shouldNotRetryNonYdbRuntimeException() { @Test void shouldImmediatelyPropagateJavaError() { - TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0); interceptor.enqueueOutcome(new OutOfMemoryError("test oom")); - assertThrows( - OutOfMemoryError.class, - () -> interceptor.invoke(invocationFor("regularTx")) - ); + assertThrows(OutOfMemoryError.class, () -> interceptor.invoke(invocationFor("regularTx"))); assertEquals(1, interceptor.allInvocations()); } @Test void shouldRetryWhenYdbStatusExtractedFromExceptionChain() throws Throwable { - TestableInterceptor interceptor = interceptorWithConfig(true, 3, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(true, 3, 0, 0, 0, 0); interceptor.enqueueOutcome( new RuntimeException("wrapped", new ConfigurableStatusException(BAD_SESSION)), "ok"); @@ -99,12 +96,9 @@ void shouldRetryWhenYdbStatusExtractedFromExceptionChain() throws Throwable { @Test void shouldCallSleeperWithBackoffDelay() throws Throwable { List delays = new ArrayList<>(); - TestableInterceptor interceptor = interceptorWithSleeper(true, 5, 0, 0, 0, 0, false, delays::add); + TestableInterceptor interceptor = interceptorWithSleeper(true, 5, 0, 0, 0, 0, delays::add); interceptor.enqueueOutcome( - new ConfigurableStatusException(ABORTED), - new ConfigurableStatusException(ABORTED), - "ok" - ); + new ConfigurableStatusException(ABORTED), new ConfigurableStatusException(ABORTED), "ok"); Object result = interceptor.invoke(invocationFor("regularTx")); @@ -119,7 +113,8 @@ void shouldCallSleeperWithBackoffDelay() throws Throwable { @Test void shouldUseZeroDelayForBadSession() throws Throwable { List delays = new ArrayList<>(); - TestableInterceptor interceptor = interceptorWithSleeper(true, 5, 100, 50, 1000, 500, false, delays::add); + TestableInterceptor interceptor = + interceptorWithSleeper(true, 5, 100, 50, 1000, 500, delays::add); interceptor.enqueueOutcome(new ConfigurableStatusException(BAD_SESSION), "ok"); Object result = interceptor.invoke(invocationFor("regularTx")); @@ -132,18 +127,26 @@ void shouldUseZeroDelayForBadSession() throws Throwable { @Test void shouldHandleInterruptedSleep() { - ConfigurableStatusException originalException = new ConfigurableStatusException(CLIENT_INTERNAL_ERROR); - TestableInterceptor interceptor = interceptorWithSleeper( - true, 3, 0, 0, 0, 0, true, delay -> { - throw new InterruptedException("sleep interrupted"); - }); + ConfigurableStatusException originalException = + new ConfigurableStatusException(CLIENT_INTERNAL_ERROR); + TestableInterceptor interceptor = + interceptorWithSleeper( + true, + 3, + 0, + 0, + 0, + 0, + delay -> { + throw new InterruptedException("sleep interrupted"); + }); interceptor.enqueueOutcome(originalException, "ok"); try { - InterruptedException thrown = assertThrows( - InterruptedException.class, - () -> interceptor.invoke(invocationFor("regularTx")) - ); + InterruptedException thrown = + assertThrows( + InterruptedException.class, + () -> interceptor.invoke(invocationFor("ydbIdempotentRetry"))); assertEquals("sleep interrupted", thrown.getMessage()); assertEquals(1, thrown.getSuppressed().length); assertSame(originalException, thrown.getSuppressed()[0]); @@ -155,52 +158,27 @@ void shouldHandleInterruptedSleep() { @Test void shouldNotRetryClientInternalErrorForTransactionalMethodWhenDefaultConfigNotIdempotent() { - TestableInterceptor interceptor = interceptorWithConfig(true, 3, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(true, 3, 0, 0, 0, 0); interceptor.enqueueOutcome(new ConfigurableStatusException(CLIENT_INTERNAL_ERROR), "ok"); - ConfigurableStatusException exception = assertThrows( - ConfigurableStatusException.class, - () -> interceptor.invoke(invocationFor("regularTx")) - ); + ConfigurableStatusException exception = + assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("regularTx"))); assertEquals(CLIENT_INTERNAL_ERROR, exception.getStatus().getCode()); assertEquals(1, interceptor.allInvocations()); } - @Test - void shouldRetryClientInternalErrorForTransactionalMethodWhenDefaultConfigIdempotent() throws Throwable { - TestableInterceptor interceptor = interceptorWithConfig(true, 3, 0, 0, 0, 0, true); - interceptor.enqueueOutcome(new ConfigurableStatusException(CLIENT_INTERNAL_ERROR), "ok"); - - Object result = interceptor.invoke(invocationFor("regularTx")); - - assertEquals("ok", result); - assertEquals(2, interceptor.allInvocations()); - } - - @Test - void shouldNotRetryTimeoutForTransactionalMethodWhenDefaultConfigIdempotent() { - TestableInterceptor interceptor = interceptorWithConfig(true, 3, 0, 0, 0, 0, true); - interceptor.enqueueOutcome(new ConfigurableStatusException(TIMEOUT)); - - ConfigurableStatusException exception = assertThrows( - ConfigurableStatusException.class, - () -> interceptor.invoke(invocationFor("regularTx")) - ); - - assertEquals(TIMEOUT, exception.getStatus().getCode()); - assertEquals(1, interceptor.allInvocations()); - } - @Test void shouldNotRetryTimeoutForTransactionalMethodWhenDefaultConfigNotIdempotent() { - TestableInterceptor interceptor = interceptorWithConfig(true, 3, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(true, 3, 0, 0, 0, 0); interceptor.enqueueOutcome(new ConfigurableStatusException(TIMEOUT)); - ConfigurableStatusException exception = assertThrows( - ConfigurableStatusException.class, - () -> interceptor.invoke(invocationFor("regularTx")) - ); + ConfigurableStatusException exception = + assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("regularTx"))); assertEquals(TIMEOUT, exception.getStatus().getCode()); assertEquals(1, interceptor.allInvocations()); @@ -208,13 +186,13 @@ void shouldNotRetryTimeoutForTransactionalMethodWhenDefaultConfigNotIdempotent() @Test void shouldNotRetryWhenDisabledInConfig() { - TestableInterceptor interceptor = interceptorWithConfig(false, 3, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(false, 3, 0, 0, 0, 0); interceptor.enqueueOutcome(new ConfigurableStatusException(BAD_SESSION), "ok"); - ConfigurableStatusException exception = assertThrows( - ConfigurableStatusException.class, - () -> interceptor.invoke(invocationFor("regularTx")) - ); + ConfigurableStatusException exception = + assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("regularTx"))); assertEquals(BAD_SESSION, exception.getStatus().getCode()); assertEquals(1, interceptor.allInvocations()); diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbDelayCalculatorTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbDelayCalculatorTest.java new file mode 100644 index 00000000..3d82f82d --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbDelayCalculatorTest.java @@ -0,0 +1,67 @@ +package tech.ydb.retry; + +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static tech.ydb.core.StatusCode.ABORTED; +import static tech.ydb.core.StatusCode.OVERLOADED; +import static tech.ydb.core.StatusCode.UNAVAILABLE; + +class YdbDelayCalculatorTest { + + @Test + void shouldCalculateBackoffFromFirstRetryWithoutZeroDelayFormula() { + YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 5, 50, 5, 5_000, 500); + + assertEquals(50, YdbDelayCalculator.calculateBackoff(50, 5_000, config.getSlowPow(), 0)); + assertEquals(5, YdbDelayCalculator.calculateBackoff(5, 500, config.getFastPow(), 0)); + } + + @Test + void shouldUseDotNetCeilingWhenCalculatingBackoffCap() { + YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 5, 1, 1, 300, 300); + + assertEquals(300, YdbDelayCalculator.calculateBackoff(1, 300, config.getSlowPow(), 9)); + assertEquals(300, YdbDelayCalculator.calculateBackoff(1, 300, config.getFastPow(), 9)); + } + + @Test + void shouldUseInclusiveRangeForFullJitter() { + YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 5, 1, 1, 1, 1); + Set observed = new HashSet<>(); + + for (int i = 0; i < 256; i++) { + long delay = YdbDelayCalculator.calculateDelay(ABORTED, config, 0); + assertTrue(delay >= 0 && delay <= 1); + observed.add(delay); + } + + assertTrue(observed.contains(0L)); + assertTrue(observed.contains(1L)); + } + + @Test + void shouldUseEqualJitterRange() { + YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 5, 2, 2, 2, 2); + Set observed = new HashSet<>(); + + for (int i = 0; i < 256; i++) { + long delay = YdbDelayCalculator.calculateDelay(UNAVAILABLE, config, 0); + assertTrue(delay >= 1 && delay <= 2); + observed.add(delay); + } + + assertTrue(observed.contains(1L)); + assertTrue(observed.contains(2L)); + } + + @Test + void shouldKeepOddRemainderForFirstOverloadedRetry() { + YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 5, 1, 1, 1, 1); + + assertEquals(1, YdbDelayCalculator.calculateDelay(OVERLOADED, config, 0)); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbRetryPolicyConfigTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbRetryPolicyConfigTest.java index 48d6e269..1bed29eb 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbRetryPolicyConfigTest.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbRetryPolicyConfigTest.java @@ -41,32 +41,38 @@ void customConstructorShouldSetValues() { @Test void shouldThrowWhenMaxRetriesIsZero() { - assertThrows(IllegalArgumentException.class, () -> new YdbRetryPolicyConfig(true, 0, 0, 0, 0, 0)); + assertThrows( + IllegalArgumentException.class, () -> new YdbRetryPolicyConfig(true, 0, 0, 0, 0, 0)); } @Test void shouldThrowWhenMaxRetriesIsNegative() { - assertThrows(IllegalArgumentException.class, () -> new YdbRetryPolicyConfig(true, -1, 0, 0, 0, 0)); + assertThrows( + IllegalArgumentException.class, () -> new YdbRetryPolicyConfig(true, -1, 0, 0, 0, 0)); } @Test void shouldThrowWhenSlowBackoffBaseIsNegative() { - assertThrows(IllegalArgumentException.class, () -> new YdbRetryPolicyConfig(true, 1, -1, 0, 0, 0)); + assertThrows( + IllegalArgumentException.class, () -> new YdbRetryPolicyConfig(true, 1, -1, 0, 0, 0)); } @Test void shouldThrowWhenFastBackoffBaseIsNegative() { - assertThrows(IllegalArgumentException.class, () -> new YdbRetryPolicyConfig(true, 1, 0, -1, 0, 0)); + assertThrows( + IllegalArgumentException.class, () -> new YdbRetryPolicyConfig(true, 1, 0, -1, 0, 0)); } @Test void shouldThrowWhenSlowCapIsNegative() { - assertThrows(IllegalArgumentException.class, () -> new YdbRetryPolicyConfig(true, 1, 0, 0, -1, 0)); + assertThrows( + IllegalArgumentException.class, () -> new YdbRetryPolicyConfig(true, 1, 0, 0, -1, 0)); } @Test void shouldThrowWhenFastCapIsNegative() { - assertThrows(IllegalArgumentException.class, () -> new YdbRetryPolicyConfig(true, 1, 0, 0, 0, -1)); + assertThrows( + IllegalArgumentException.class, () -> new YdbRetryPolicyConfig(true, 1, 0, 0, 0, -1)); } @Test @@ -80,8 +86,8 @@ void mergeWithDefaultAnnotationShouldKeepConfigValues() throws NoSuchMethodExcep YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300); Method method = YdbTransactionalTestService.class.getMethod("defaultRetry"); - YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); - + YdbTransactional annotation = + AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); YdbRetryPolicyConfig merged = original.merge(annotation); @@ -97,7 +103,8 @@ void mergeWithCustomAnnotationShouldOverride() throws NoSuchMethodException { YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300); Method method = YdbTransactionalTestService.class.getMethod("ydbNewTransactionSettings"); - YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); + YdbTransactional annotation = + AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); YdbRetryPolicyConfig merged = original.merge(annotation); @@ -113,7 +120,8 @@ void mergeWithPartialOverrideShouldOnlyChangeSpecifiedValues() throws NoSuchMeth YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300); Method method = YdbTransactionalTestService.class.getMethod("ydbCustomRetry"); - YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); + YdbTransactional annotation = + AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); YdbRetryPolicyConfig merged = original.merge(annotation); @@ -130,19 +138,34 @@ void shouldThrowWhenYdbTransactionalMaxRetriesIsNegative() throws NoSuchMethodEx YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300); Method method = YdbTransactionalTestService.class.getMethod("ydbNegativeMaxRetries"); - YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); + YdbTransactional annotation = + AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); assertThrows(IllegalArgumentException.class, () -> original.merge(annotation)); } + @Test + void shouldRejectZeroMaxRetriesAtAnnotationMergeTime() throws NoSuchMethodException { + YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300); + + Method method = YdbTransactionalTestService.class.getMethod("ydbZeroMaxRetries"); + YdbTransactional annotation = + AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> original.merge(annotation)); + + assertEquals( + "maxRetries must not be 0; use enabled = false to disable retry", exception.getMessage()); + } + @Test void getJitterShouldReturnValueWithinRange() { YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(); long bound = 100; for (int i = 0; i < 50; i++) { long jitter = config.getJitter(bound); - assertTrue(jitter >= 0 && jitter < bound, - "Jitter " + jitter + " out of range [0, " + bound + ")"); + assertTrue(jitter >= 0 && jitter <= bound); } } @@ -150,8 +173,8 @@ void getJitterShouldReturnValueWithinRange() { void powShouldBeComputedFromCapValues() { YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300); - int expectedSlowPow = (int) (Math.log(2000) / Math.log(2)); - int expectedFastPow = (int) (Math.log(300) / Math.log(2)); + int expectedSlowPow = Integer.SIZE - Integer.numberOfLeadingZeros(2000); + int expectedFastPow = Integer.SIZE - Integer.numberOfLeadingZeros(300); assertEquals(expectedSlowPow, config.getSlowPow()); assertEquals(expectedFastPow, config.getFastPow()); @@ -165,76 +188,17 @@ void powForSmallCapShouldBeOne() { } @Test - void powForZeroCapShouldBeOne() { - YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 1, 0, 0, 0, 0); - assertEquals(1, config.getSlowPow()); - assertEquals(1, config.getFastPow()); - } - - @Test - void defaultConstructorShouldSetIdempotentFalse() { - YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(); - assertFalse(config.isIdempotent()); - } - - @Test - void fiveArgConstructorShouldSetIdempotentFalse() { - YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300); - assertFalse(config.isIdempotent()); - } - - @Test - void sixArgConstructorShouldSetIdempotentTrue() { - YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300, true); - assertTrue(config.isIdempotent()); - } - - @Test - void mergeWithIdempotentAnnotationShouldSetIdempotentTrue() throws NoSuchMethodException { - YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300, false); - - Method method = YdbTransactionalTestService.class.getMethod("ydbIdempotentRetry"); - YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); - - YdbRetryPolicyConfig merged = original.merge(annotation); - - assertTrue(merged.isIdempotent()); + void powForCapTwoShouldBeTwo() { + YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 1, 0, 0, 2, 2); + assertEquals(2, config.getSlowPow()); + assertEquals(2, config.getFastPow()); } @Test - void mergeWithNonIdempotentAnnotationShouldSetIdempotentFalse() throws NoSuchMethodException { - YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300, true); - - Method method = YdbTransactionalTestService.class.getMethod("ydbNonIdempotentRetry"); - YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); - - YdbRetryPolicyConfig merged = original.merge(annotation); - - assertFalse(merged.isIdempotent()); - } - - @Test - void mergeWithDefaultAnnotationShouldInheritIdempotentFromConfig() throws NoSuchMethodException { - YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300, true); - - Method method = YdbTransactionalTestService.class.getMethod("defaultRetry"); - YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); - - YdbRetryPolicyConfig merged = original.merge(annotation); - - assertTrue(merged.isIdempotent()); - } - - @Test - void mergeWithDefaultAnnotationShouldInheritIdempotentFalseFromConfig() throws NoSuchMethodException { - YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300, false); - - Method method = YdbTransactionalTestService.class.getMethod("defaultRetry"); - YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); - - YdbRetryPolicyConfig merged = original.merge(annotation); - - assertFalse(merged.isIdempotent()); + void powForZeroCapShouldBeZero() { + YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 1, 0, 0, 0, 0); + assertEquals(0, config.getSlowPow()); + assertEquals(0, config.getFastPow()); } @Test @@ -254,7 +218,8 @@ void mergeShouldPreserveEnabledFromBaseConfig() throws NoSuchMethodException { YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(false, 5, 100, 20, 2000, 300); Method method = YdbTransactionalTestService.class.getMethod("ydbCustomRetry"); - YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); + YdbTransactional annotation = + AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); YdbRetryPolicyConfig merged = original.merge(annotation); @@ -266,7 +231,8 @@ void mergeShouldKeepEnabledTrueWhenConfigEnabled() throws NoSuchMethodException YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300); Method method = YdbTransactionalTestService.class.getMethod("defaultRetry"); - YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); + YdbTransactional annotation = + AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); YdbRetryPolicyConfig merged = original.merge(annotation); @@ -274,11 +240,13 @@ void mergeShouldKeepEnabledTrueWhenConfigEnabled() throws NoSuchMethodException } @Test - void mergeWithDefaultAnnotationShouldKeepEnabledFalseWhenConfigDisabled() throws NoSuchMethodException { + void mergeWithDefaultAnnotationShouldKeepEnabledFalseWhenConfigDisabled() + throws NoSuchMethodException { YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(false, 5, 100, 20, 2000, 300); Method method = YdbTransactionalTestService.class.getMethod("defaultRetry"); - YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); + YdbTransactional annotation = + AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); YdbRetryPolicyConfig merged = original.merge(annotation); @@ -290,7 +258,8 @@ void mergeWithDisabledAnnotationShouldSetEnabledFalse() throws NoSuchMethodExcep YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300); Method method = YdbTransactionalTestService.class.getMethod("ydbRetryDisabled"); - YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); + YdbTransactional annotation = + AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); YdbRetryPolicyConfig merged = original.merge(annotation); @@ -298,11 +267,13 @@ void mergeWithDisabledAnnotationShouldSetEnabledFalse() throws NoSuchMethodExcep } @Test - void mergeWithEnabledAnnotationShouldNotOverrideDisabledGlobalConfig() throws NoSuchMethodException { + void mergeWithEnabledAnnotationShouldNotOverrideDisabledGlobalConfig() + throws NoSuchMethodException { YdbRetryPolicyConfig original = new YdbRetryPolicyConfig(false, 5, 100, 20, 2000, 300); Method method = YdbTransactionalTestService.class.getMethod("ydbRetryEnabled"); - YdbTransactional annotation = AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); + YdbTransactional annotation = + AnnotatedElementUtils.findMergedAnnotation(method, YdbTransactional.class); YdbRetryPolicyConfig merged = original.merge(annotation); diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbRetryPolicyTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbRetryPolicyTest.java index 8051f0e2..865f368e 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbRetryPolicyTest.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbRetryPolicyTest.java @@ -31,69 +31,64 @@ class YdbRetryPolicyTest { @Test void shouldRetryAlwaysRetryableStatusesRegardlessOfIdempotence() { - List alwaysRetryable = List.of( - BAD_SESSION, - SESSION_BUSY, - ABORTED, - UNAVAILABLE, - OVERLOADED, - CLIENT_RESOURCE_EXHAUSTED - ); + List alwaysRetryable = + List.of( + BAD_SESSION, SESSION_BUSY, ABORTED, UNAVAILABLE, OVERLOADED, CLIENT_RESOURCE_EXHAUSTED); for (StatusCode code : alwaysRetryable) { - assertTrue(YdbRetryPolicy.shouldRetry(code, false), "Should retry " + code + " when not idempotent"); - assertTrue(YdbRetryPolicy.shouldRetry(code, true), "Should retry " + code + " when idempotent"); + assertTrue( + YdbRetryPolicy.shouldRetry(code, false), "Should retry " + code + " when not idempotent"); + assertTrue( + YdbRetryPolicy.shouldRetry(code, true), "Should retry " + code + " when idempotent"); } } @Test void shouldNotRetryIdempotentOnlyStatusesWhenNotIdempotent() { - List idempotentOnly = List.of( - CLIENT_CANCELLED, - CLIENT_INTERNAL_ERROR, - TRANSPORT_UNAVAILABLE, - UNDETERMINED - ); + List idempotentOnly = + List.of(CLIENT_CANCELLED, CLIENT_INTERNAL_ERROR, TRANSPORT_UNAVAILABLE, UNDETERMINED); for (StatusCode code : idempotentOnly) { - assertFalse(YdbRetryPolicy.shouldRetry(code, false), "Should not retry " + code + " when not idempotent"); + assertFalse( + YdbRetryPolicy.shouldRetry(code, false), + "Should not retry " + code + " when not idempotent"); } } @Test void shouldRetryIdempotentOnlyStatusesWhenIdempotent() { - List idempotentOnly = List.of( - CLIENT_CANCELLED, - CLIENT_INTERNAL_ERROR, - TRANSPORT_UNAVAILABLE, - UNDETERMINED - ); + List idempotentOnly = + List.of(CLIENT_CANCELLED, CLIENT_INTERNAL_ERROR, TRANSPORT_UNAVAILABLE, UNDETERMINED); for (StatusCode code : idempotentOnly) { - assertTrue(YdbRetryPolicy.shouldRetry(code, true), "Should retry " + code + " when idempotent"); + assertTrue( + YdbRetryPolicy.shouldRetry(code, true), "Should retry " + code + " when idempotent"); } } @Test void shouldNotRetryNonRetryableStatuses() { - List nonRetryable = List.of( - StatusCode.SUCCESS, - BAD_REQUEST, - UNAUTHORIZED, - INTERNAL_ERROR, - SCHEME_ERROR, - GENERIC_ERROR, - NOT_FOUND, - UNSUPPORTED, - CANCELLED, - EXTERNAL_ERROR, - TIMEOUT, - SESSION_EXPIRED - ); + List nonRetryable = + List.of( + StatusCode.SUCCESS, + BAD_REQUEST, + UNAUTHORIZED, + INTERNAL_ERROR, + SCHEME_ERROR, + GENERIC_ERROR, + NOT_FOUND, + UNSUPPORTED, + CANCELLED, + EXTERNAL_ERROR, + TIMEOUT, + SESSION_EXPIRED); for (StatusCode code : nonRetryable) { - assertFalse(YdbRetryPolicy.shouldRetry(code, false), "Should not retry " + code + " when not idempotent"); - assertFalse(YdbRetryPolicy.shouldRetry(code, true), "Should not retry " + code + " when idempotent"); + assertFalse( + YdbRetryPolicy.shouldRetry(code, false), + "Should not retry " + code + " when not idempotent"); + assertFalse( + YdbRetryPolicy.shouldRetry(code, true), "Should not retry " + code + " when idempotent"); } } diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorFactoryTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorFactoryTest.java index 5c91858f..b03385d7 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorFactoryTest.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorFactoryTest.java @@ -34,7 +34,6 @@ void getObjectShouldUseRetryPropertiesConfig() { YdbRetryProperties properties = new YdbRetryProperties(); properties.setEnabled(false); properties.setMaxRetries(3); - properties.setIdempotent(true); YdbTransactionInterceptorFactory factory = new YdbTransactionInterceptorFactory(); factory.setRetryProperties(properties); factory.setTransactionAttributeSource(new AnnotationTransactionAttributeSource()); diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorReplacerTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorReplacerTest.java index ead4da6c..1db1a70f 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorReplacerTest.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorReplacerTest.java @@ -43,20 +43,25 @@ void shouldSkipWhenTransactionInterceptorNotFound() { @Test void shouldSkipWhenAlreadyYdbTransactionInterceptorFactory() { DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); - BeanDefinition beanDefinition = BeanDefinitionBuilder.genericBeanDefinition(YdbTransactionInterceptorFactory.class).getBeanDefinition(); + BeanDefinition beanDefinition = + BeanDefinitionBuilder.genericBeanDefinition(YdbTransactionInterceptorFactory.class) + .getBeanDefinition(); beanFactory.registerBeanDefinition("transactionInterceptor", beanDefinition); YdbTransactionInterceptorReplacer pp = new YdbTransactionInterceptorReplacer(); pp.postProcessBeanDefinitionRegistry(beanFactory); - String beanClassName = beanFactory.getBeanDefinition("transactionInterceptor").getBeanClassName(); + String beanClassName = + beanFactory.getBeanDefinition("transactionInterceptor").getBeanClassName(); assertEquals(YdbTransactionInterceptorFactory.class.getName(), beanClassName); } @Test void shouldReplaceStandardTransactionInterceptorBeanDefinition() { DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); - BeanDefinition beanDefinition = BeanDefinitionBuilder.genericBeanDefinition(TransactionInterceptor.class).getBeanDefinition(); + BeanDefinition beanDefinition = + BeanDefinitionBuilder.genericBeanDefinition(TransactionInterceptor.class) + .getBeanDefinition(); beanFactory.registerBeanDefinition("transactionInterceptor", beanDefinition); PlatformTransactionManager txManager = Mockito.mock(PlatformTransactionManager.class); @@ -71,12 +76,14 @@ void shouldReplaceStandardTransactionInterceptorBeanDefinition() { pp.postProcessBeanDefinitionRegistry(beanFactory); beanDefinition = beanFactory.getBeanDefinition("transactionInterceptor"); - assertEquals(YdbTransactionInterceptorFactory.class.getName(), beanDefinition.getBeanClassName()); + assertEquals( + YdbTransactionInterceptorFactory.class.getName(), beanDefinition.getBeanClassName()); Object bean = beanFactory.getBean("transactionInterceptor"); assertInstanceOf(YdbTransactionInterceptor.class, bean); - Map interceptors = beanFactory.getBeansOfType(TransactionInterceptor.class); + Map interceptors = + beanFactory.getBeansOfType(TransactionInterceptor.class); assertEquals(1, interceptors.size()); assertSame(bean, interceptors.get("transactionInterceptor")); } @@ -84,7 +91,9 @@ void shouldReplaceStandardTransactionInterceptorBeanDefinition() { @Test void shouldRegisterInterceptorWithCorrectProperties() { DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); - BeanDefinition beanDefinition = BeanDefinitionBuilder.genericBeanDefinition(TransactionInterceptor.class).getBeanDefinition(); + BeanDefinition beanDefinition = + BeanDefinitionBuilder.genericBeanDefinition(TransactionInterceptor.class) + .getBeanDefinition(); beanFactory.registerBeanDefinition("transactionInterceptor", beanDefinition); PlatformTransactionManager txManager = Mockito.mock(PlatformTransactionManager.class); @@ -107,9 +116,9 @@ void shouldRegisterInterceptorWithCorrectProperties() { @Test void shouldPreserveBeanDefinitionMetadataWhenReplacingInterceptor() { DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); - AbstractBeanDefinition beanDefinition = BeanDefinitionBuilder - .genericBeanDefinition(TransactionInterceptor.class) - .getBeanDefinition(); + AbstractBeanDefinition beanDefinition = + BeanDefinitionBuilder.genericBeanDefinition(TransactionInterceptor.class) + .getBeanDefinition(); beanDefinition.setPrimary(true); beanDefinition.setFallback(true); beanDefinition.setLazyInit(true); @@ -127,20 +136,26 @@ void shouldPreserveBeanDefinitionMetadataWhenReplacingInterceptor() { ByteArrayResource resource = new ByteArrayResource(new byte[0], "tx-resource"); beanDefinition.setResource(resource); beanDefinition.setResourceDescription("tx-resource-description"); - BeanDefinition originatingBeanDefinition = BeanDefinitionBuilder.genericBeanDefinition(Object.class).getBeanDefinition(); + BeanDefinition originatingBeanDefinition = + BeanDefinitionBuilder.genericBeanDefinition(Object.class).getBeanDefinition(); beanDefinition.setOriginatingBeanDefinition(originatingBeanDefinition); Object source = new Object(); beanDefinition.setSource(source); - beanFactory.registerBeanDefinition("txParent", BeanDefinitionBuilder.genericBeanDefinition(Object.class).getBeanDefinition()); - beanFactory.registerBeanDefinition("txDependency", BeanDefinitionBuilder.genericBeanDefinition(Object.class).getBeanDefinition()); + beanFactory.registerBeanDefinition( + "txParent", BeanDefinitionBuilder.genericBeanDefinition(Object.class).getBeanDefinition()); + beanFactory.registerBeanDefinition( + "txDependency", + BeanDefinitionBuilder.genericBeanDefinition(Object.class).getBeanDefinition()); beanFactory.registerBeanDefinition("transactionInterceptor", beanDefinition); beanFactory.registerSingleton(YdbRetryProperties.class.getName(), new YdbRetryProperties()); - beanFactory.registerSingleton(TransactionAttributeSource.class.getName(), new AnnotationTransactionAttributeSource()); + beanFactory.registerSingleton( + TransactionAttributeSource.class.getName(), new AnnotationTransactionAttributeSource()); YdbTransactionInterceptorReplacer pp = new YdbTransactionInterceptorReplacer(); pp.postProcessBeanDefinitionRegistry(beanFactory); - AbstractBeanDefinition replaced = (AbstractBeanDefinition) beanFactory.getBeanDefinition("transactionInterceptor"); + AbstractBeanDefinition replaced = + (AbstractBeanDefinition) beanFactory.getBeanDefinition("transactionInterceptor"); assertEquals(YdbTransactionInterceptorFactory.class.getName(), replaced.getBeanClassName()); assertTrue(replaced.isPrimary()); assertTrue(replaced.isFallback()); @@ -157,7 +172,8 @@ void shouldPreserveBeanDefinitionMetadataWhenReplacingInterceptor() { assertTrue(replaced.hasQualifier(String.class.getName())); assertEquals(true, replaced.getAttribute("preserveTargetClass")); assertEquals(beanDefinition.getResource().getClass(), replaced.getResource().getClass()); - assertEquals(beanDefinition.getResource().getDescription(), replaced.getResource().getDescription()); + assertEquals( + beanDefinition.getResource().getDescription(), replaced.getResource().getDescription()); assertEquals(beanDefinition.getResourceDescription(), replaced.getResourceDescription()); assertSame(originatingBeanDefinition, replaced.getOriginatingBeanDefinition()); assertSame(source, replaced.getSource()); diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionManagerResolutionTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionManagerResolutionTest.java index 97af161e..4353cccb 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionManagerResolutionTest.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionManagerResolutionTest.java @@ -29,9 +29,11 @@ class YdbTransactionManagerResolutionTest { @Test void shouldUseSingleManager() { - try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(SingleManagerConfig.class)) { + try (AnnotationConfigApplicationContext context = + new AnnotationConfigApplicationContext(SingleManagerConfig.class)) { SingleManagerService service = context.getBean(SingleManagerService.class); - RecordingTransactionManager manager = context.getBean("singleManager", RecordingTransactionManager.class); + RecordingTransactionManager manager = + context.getBean("singleManager", RecordingTransactionManager.class); service.defaultOperation(); @@ -45,10 +47,13 @@ void shouldUseSingleManager() { @Test void shouldResolveExplicitTransactionManagersWithoutPrimary() { - try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(MultiManagerConfig.class)) { + try (AnnotationConfigApplicationContext context = + new AnnotationConfigApplicationContext(MultiManagerConfig.class)) { MultiManagerService service = context.getBean(MultiManagerService.class); - RecordingTransactionManager ydbManager = context.getBean("ydbTransactionManager", RecordingTransactionManager.class); - RecordingTransactionManager auditManager = context.getBean("auditTransactionManager", RecordingTransactionManager.class); + RecordingTransactionManager ydbManager = + context.getBean("ydbTransactionManager", RecordingTransactionManager.class); + RecordingTransactionManager auditManager = + context.getBean("auditTransactionManager", RecordingTransactionManager.class); service.ydbOperation(); @@ -77,10 +82,14 @@ void shouldResolveExplicitTransactionManagersWithoutPrimary() { @Test void shouldUseConfigurerDefaultTransactionManager() { - try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(ConfigurerDefaultManagerConfig.class)) { - ConfigurerDefaultManagerService service = context.getBean(ConfigurerDefaultManagerService.class); - RecordingTransactionManager ydbManager = context.getBean("ydbTransactionManager", RecordingTransactionManager.class); - RecordingTransactionManager auditManager = context.getBean("auditTransactionManager", RecordingTransactionManager.class); + try (AnnotationConfigApplicationContext context = + new AnnotationConfigApplicationContext(ConfigurerDefaultManagerConfig.class)) { + ConfigurerDefaultManagerService service = + context.getBean(ConfigurerDefaultManagerService.class); + RecordingTransactionManager ydbManager = + context.getBean("ydbTransactionManager", RecordingTransactionManager.class); + RecordingTransactionManager auditManager = + context.getBean("auditTransactionManager", RecordingTransactionManager.class); service.defaultSpringOperation(); @@ -103,10 +112,13 @@ void shouldUseConfigurerDefaultTransactionManager() { @Test void shouldUsePrimaryTransactionManager() { - try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(PrimaryManagerConfig.class)) { + try (AnnotationConfigApplicationContext context = + new AnnotationConfigApplicationContext(PrimaryManagerConfig.class)) { PrimaryManagerService service = context.getBean(PrimaryManagerService.class); - RecordingTransactionManager primaryManager = context.getBean("primaryTransactionManager", RecordingTransactionManager.class); - RecordingTransactionManager secondaryManager = context.getBean("secondaryTransactionManager", RecordingTransactionManager.class); + RecordingTransactionManager primaryManager = + context.getBean("primaryTransactionManager", RecordingTransactionManager.class); + RecordingTransactionManager secondaryManager = + context.getBean("secondaryTransactionManager", RecordingTransactionManager.class); service.defaultSpringOperation(); @@ -129,12 +141,15 @@ void shouldUsePrimaryTransactionManager() { @Test void ydbTransactionalAliasShouldExposeTransactionManagerQualifier() throws NoSuchMethodException { - AnnotationTransactionAttributeSource attributeSource = new AnnotationTransactionAttributeSource(); + AnnotationTransactionAttributeSource attributeSource = + new AnnotationTransactionAttributeSource(); Method ydbMethod = MultiManagerService.class.getMethod("ydbOperation"); Method auditMethod = MultiManagerService.class.getMethod("auditOperation"); - TransactionAttribute ydbAttribute = attributeSource.getTransactionAttribute(ydbMethod, MultiManagerService.class); - TransactionAttribute auditAttribute = attributeSource.getTransactionAttribute(auditMethod, MultiManagerService.class); + TransactionAttribute ydbAttribute = + attributeSource.getTransactionAttribute(ydbMethod, MultiManagerService.class); + TransactionAttribute auditAttribute = + attributeSource.getTransactionAttribute(auditMethod, MultiManagerService.class); assertNotNull(ydbAttribute); assertNotNull(auditAttribute); @@ -143,11 +158,14 @@ void ydbTransactionalAliasShouldExposeTransactionManagerQualifier() throws NoSuc } @Test - void ydbTransactionalValueAliasShouldExposeTransactionManagerQualifier() throws NoSuchMethodException { - AnnotationTransactionAttributeSource attributeSource = new AnnotationTransactionAttributeSource(); + void ydbTransactionalValueAliasShouldExposeTransactionManagerQualifier() + throws NoSuchMethodException { + AnnotationTransactionAttributeSource attributeSource = + new AnnotationTransactionAttributeSource(); Method method = MultiManagerService.class.getMethod("ydbValueAliasOperation"); - TransactionAttribute attribute = attributeSource.getTransactionAttribute(method, MultiManagerService.class); + TransactionAttribute attribute = + attributeSource.getTransactionAttribute(method, MultiManagerService.class); assertNotNull(attribute); assertEquals("ydbTransactionManager", attribute.getQualifier()); @@ -155,10 +173,12 @@ void ydbTransactionalValueAliasShouldExposeTransactionManagerQualifier() throws @Test void ydbTransactionalTimeoutStringShouldExposeTimeout() throws NoSuchMethodException { - AnnotationTransactionAttributeSource attributeSource = new AnnotationTransactionAttributeSource(); + AnnotationTransactionAttributeSource attributeSource = + new AnnotationTransactionAttributeSource(); Method method = MultiManagerService.class.getMethod("ydbTimeoutStringOperation"); - TransactionAttribute attribute = attributeSource.getTransactionAttribute(method, MultiManagerService.class); + TransactionAttribute attribute = + attributeSource.getTransactionAttribute(method, MultiManagerService.class); assertNotNull(attribute); assertEquals(15, attribute.getTimeout()); diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionalConfigOverrideTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionalConfigOverrideTest.java index 56dd8906..8db06f20 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionalConfigOverrideTest.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionalConfigOverrideTest.java @@ -22,7 +22,7 @@ class YdbTransactionalConfigOverrideTest extends InterceptorTestSupport { @Test void shouldOverrideMaxRetriesFromAnnotation() throws Throwable { - TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0); interceptor.enqueueOutcome(new ConfigurableStatusException(ABORTED), "ok"); Object result = interceptor.invoke(invocationFor("ydbCustomRetry")); @@ -34,7 +34,7 @@ void shouldOverrideMaxRetriesFromAnnotation() throws Throwable { @Test void shouldUseConfigMaxRetriesWhenAnnotationNotSet() throws Throwable { - TestableInterceptor interceptor = interceptorWithConfig(true, 2, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(true, 2, 0, 0, 0, 0); interceptor.enqueueOutcome(new ConfigurableStatusException(BAD_SESSION), "ok"); Object result = interceptor.invoke(invocationFor("defaultRetry")); @@ -46,13 +46,16 @@ void shouldUseConfigMaxRetriesWhenAnnotationNotSet() throws Throwable { @Test void shouldExhaustAnnotatedMaxRetriesAndPropagate() { - TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0, false); - interceptor.enqueueOutcome(new ConfigurableStatusException(SESSION_BUSY), new ConfigurableStatusException(OVERLOADED), new ConfigurableStatusException(OVERLOADED)); + TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0); + interceptor.enqueueOutcome( + new ConfigurableStatusException(SESSION_BUSY), + new ConfigurableStatusException(OVERLOADED), + new ConfigurableStatusException(OVERLOADED)); - ConfigurableStatusException exception = assertThrows( - ConfigurableStatusException.class, - () -> interceptor.invoke(invocationFor("ydbCustomRetry")) - ); + ConfigurableStatusException exception = + assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("ydbCustomRetry"))); assertEquals(OVERLOADED, exception.getStatus().getCode()); assertEquals(2, interceptor.retries()); @@ -61,14 +64,16 @@ void shouldExhaustAnnotatedMaxRetriesAndPropagate() { @Test void shouldUseAnnotatedMaxRetriesWhenLowerThanConfig() { - TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0); interceptor.enqueueOutcome( - new ConfigurableStatusException(OVERLOADED), new ConfigurableStatusException(BAD_SESSION), new ConfigurableStatusException(OVERLOADED)); + new ConfigurableStatusException(OVERLOADED), + new ConfigurableStatusException(BAD_SESSION), + new ConfigurableStatusException(OVERLOADED)); - ConfigurableStatusException exception = assertThrows( - ConfigurableStatusException.class, - () -> interceptor.invoke(invocationFor("ydbCustomRetry")) - ); + ConfigurableStatusException exception = + assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("ydbCustomRetry"))); assertEquals(OVERLOADED, exception.getStatus().getCode()); assertEquals(2, interceptor.retries()); @@ -77,10 +82,12 @@ void shouldUseAnnotatedMaxRetriesWhenLowerThanConfig() { @Test void shouldUseAnnotatedMaxRetriesWhenHigherThanConfig() throws Throwable { - TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0); interceptor.enqueueOutcome( - new ConfigurableStatusException(BAD_SESSION), new ConfigurableStatusException(SESSION_BUSY), - new ConfigurableStatusException(ABORTED), new ConfigurableStatusException(OVERLOADED), + new ConfigurableStatusException(BAD_SESSION), + new ConfigurableStatusException(SESSION_BUSY), + new ConfigurableStatusException(ABORTED), + new ConfigurableStatusException(OVERLOADED), "ok"); Object result = interceptor.invoke(invocationFor("ydbRequiredRetry")); @@ -91,7 +98,7 @@ void shouldUseAnnotatedMaxRetriesWhenHigherThanConfig() throws Throwable { @Test void shouldRetryDifferentStatusCodesAcrossRetries() throws Throwable { - TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0); interceptor.enqueueOutcome( new ConfigurableStatusException(ABORTED), new ConfigurableStatusException(BAD_SESSION), @@ -105,13 +112,13 @@ void shouldRetryDifferentStatusCodesAcrossRetries() throws Throwable { @Test void shouldNotRetryClientCancelledWhenNotIdempotent() { - TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, true); + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0); interceptor.enqueueOutcome(new ConfigurableStatusException(CLIENT_CANCELLED), "ok"); - ConfigurableStatusException exception = assertThrows( - ConfigurableStatusException.class, - () -> interceptor.invoke(invocationFor("ydbNonIdempotentRetry")) - ); + ConfigurableStatusException exception = + assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("ydbNonIdempotentRetry"))); assertEquals(CLIENT_CANCELLED, exception.getStatus().getCode()); assertEquals(1, interceptor.allInvocations()); @@ -119,7 +126,7 @@ void shouldNotRetryClientCancelledWhenNotIdempotent() { @Test void shouldRetryClientCancelledWhenIdempotent() throws Throwable { - TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0); interceptor.enqueueOutcome(new ConfigurableStatusException(CLIENT_CANCELLED), "ok"); Object result = interceptor.invoke(invocationFor("ydbIdempotentRetry")); @@ -130,13 +137,13 @@ void shouldRetryClientCancelledWhenIdempotent() throws Throwable { @Test void shouldNotRetryTransportUnavailableWhenNotIdempotent() { - TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, true); + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0); interceptor.enqueueOutcome(new ConfigurableStatusException(TRANSPORT_UNAVAILABLE), "ok"); - ConfigurableStatusException exception = assertThrows( - ConfigurableStatusException.class, - () -> interceptor.invoke(invocationFor("ydbNonIdempotentRetry")) - ); + ConfigurableStatusException exception = + assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("ydbNonIdempotentRetry"))); assertEquals(TRANSPORT_UNAVAILABLE, exception.getStatus().getCode()); assertEquals(1, interceptor.allInvocations()); @@ -144,7 +151,7 @@ void shouldNotRetryTransportUnavailableWhenNotIdempotent() { @Test void shouldRetryTransportUnavailableWhenIdempotent() throws Throwable { - TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0); interceptor.enqueueOutcome(new ConfigurableStatusException(TRANSPORT_UNAVAILABLE), "ok"); Object result = interceptor.invoke(invocationFor("ydbIdempotentRetry")); @@ -155,7 +162,7 @@ void shouldRetryTransportUnavailableWhenIdempotent() throws Throwable { @Test void shouldRetryClientResourceExhaustedWhenNotIdempotent() throws Throwable { - TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0); interceptor.enqueueOutcome(new ConfigurableStatusException(CLIENT_RESOURCE_EXHAUSTED), "ok"); Object result = interceptor.invoke(invocationFor("ydbNonIdempotentRetry")); @@ -166,7 +173,7 @@ void shouldRetryClientResourceExhaustedWhenNotIdempotent() throws Throwable { @Test void shouldRetryClientResourceExhaustedWhenIdempotent() throws Throwable { - TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0); interceptor.enqueueOutcome(new ConfigurableStatusException(CLIENT_RESOURCE_EXHAUSTED), "ok"); Object result = interceptor.invoke(invocationFor("ydbIdempotentRetry")); @@ -177,13 +184,13 @@ void shouldRetryClientResourceExhaustedWhenIdempotent() throws Throwable { @Test void shouldNotRetryTimeoutWhenIdempotent() { - TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, true); + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0); interceptor.enqueueOutcome(new ConfigurableStatusException(TIMEOUT)); - ConfigurableStatusException exception = assertThrows( - ConfigurableStatusException.class, - () -> interceptor.invoke(invocationFor("ydbIdempotentRetry")) - ); + ConfigurableStatusException exception = + assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("ydbIdempotentRetry"))); assertEquals(TIMEOUT, exception.getStatus().getCode()); assertEquals(1, interceptor.allInvocations()); @@ -191,13 +198,13 @@ void shouldNotRetryTimeoutWhenIdempotent() { @Test void shouldNotRetrySessionExpiredWhenNotIdempotent() { - TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, true); + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0); interceptor.enqueueOutcome(new ConfigurableStatusException(SESSION_EXPIRED)); - ConfigurableStatusException exception = assertThrows( - ConfigurableStatusException.class, - () -> interceptor.invoke(invocationFor("ydbNonIdempotentRetry")) - ); + ConfigurableStatusException exception = + assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("ydbNonIdempotentRetry"))); assertEquals(SESSION_EXPIRED, exception.getStatus().getCode()); assertEquals(1, interceptor.allInvocations()); @@ -205,7 +212,7 @@ void shouldNotRetrySessionExpiredWhenNotIdempotent() { @Test void shouldRetryAlwaysRetryableCodesWhenIdempotent() throws Throwable { - TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, true); + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0); interceptor.enqueueOutcome(new ConfigurableStatusException(ABORTED), "ok"); Object result = interceptor.invoke(invocationFor("ydbIdempotentRetry")); @@ -216,13 +223,12 @@ void shouldRetryAlwaysRetryableCodesWhenIdempotent() throws Throwable { @Test void shouldRetryMixedStatusCodesWhenIdempotent() throws Throwable { - TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, true); + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0); interceptor.enqueueOutcome( new ConfigurableStatusException(ABORTED), new ConfigurableStatusException(UNDETERMINED), new ConfigurableStatusException(CLIENT_CANCELLED), - "ok" - ); + "ok"); Object result = interceptor.invoke(invocationFor("ydbIdempotentRetry")); @@ -232,13 +238,13 @@ void shouldRetryMixedStatusCodesWhenIdempotent() throws Throwable { @Test void shouldNotRetrySessionExpiredWhenIdempotent() { - TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, true); + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0); interceptor.enqueueOutcome(new ConfigurableStatusException(SESSION_EXPIRED)); - ConfigurableStatusException exception = assertThrows( - ConfigurableStatusException.class, - () -> interceptor.invoke(invocationFor("ydbIdempotentRetry")) - ); + ConfigurableStatusException exception = + assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("ydbIdempotentRetry"))); assertEquals(SESSION_EXPIRED, exception.getStatus().getCode()); assertEquals(1, interceptor.allInvocations()); @@ -246,16 +252,14 @@ void shouldNotRetrySessionExpiredWhenIdempotent() { @Test void shouldStopAtIdempotentOnlyCodeWhenNotIdempotent() { - TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0); interceptor.enqueueOutcome( - new ConfigurableStatusException(BAD_SESSION), - new ConfigurableStatusException(TIMEOUT) - ); + new ConfigurableStatusException(BAD_SESSION), new ConfigurableStatusException(TIMEOUT)); - ConfigurableStatusException exception = assertThrows( - ConfigurableStatusException.class, - () -> interceptor.invoke(invocationFor("ydbNonIdempotentRetry")) - ); + ConfigurableStatusException exception = + assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("ydbNonIdempotentRetry"))); assertEquals(TIMEOUT, exception.getStatus().getCode()); assertEquals(2, interceptor.allInvocations()); @@ -264,13 +268,13 @@ void shouldStopAtIdempotentOnlyCodeWhenNotIdempotent() { @Test void shouldNotReachDelayCalculatorForTimeoutWhenIdempotent() { List delays = new ArrayList<>(); - TestableInterceptor interceptor = interceptorWithSleeper(true, 5, 100, 50, 1000, 500, true, delays::add); + TestableInterceptor interceptor = + interceptorWithSleeper(true, 5, 100, 50, 1000, 500, delays::add); interceptor.enqueueOutcome(new ConfigurableStatusException(TIMEOUT)); assertThrows( ConfigurableStatusException.class, - () -> interceptor.invoke(invocationFor("ydbIdempotentRetry")) - ); + () -> interceptor.invoke(invocationFor("ydbIdempotentRetry"))); assertEquals(1, interceptor.allInvocations()); assertEquals(0, delays.size()); @@ -279,13 +283,13 @@ void shouldNotReachDelayCalculatorForTimeoutWhenIdempotent() { @Test void shouldNotReachDelayCalculatorForSessionExpiredWhenIdempotent() { List delays = new ArrayList<>(); - TestableInterceptor interceptor = interceptorWithSleeper(true, 5, 100, 50, 1000, 500, true, delays::add); + TestableInterceptor interceptor = + interceptorWithSleeper(true, 5, 100, 50, 1000, 500, delays::add); interceptor.enqueueOutcome(new ConfigurableStatusException(SESSION_EXPIRED)); assertThrows( ConfigurableStatusException.class, - () -> interceptor.invoke(invocationFor("ydbIdempotentRetry")) - ); + () -> interceptor.invoke(invocationFor("ydbIdempotentRetry"))); assertEquals(1, interceptor.allInvocations()); assertEquals(0, delays.size()); @@ -294,7 +298,8 @@ void shouldNotReachDelayCalculatorForSessionExpiredWhenIdempotent() { @Test void shouldUseFastBackoffForUndeterminedWhenIdempotent() throws Throwable { List delays = new ArrayList<>(); - TestableInterceptor interceptor = interceptorWithSleeper(true, 5, 100, 50, 1000, 500, true, delays::add); + TestableInterceptor interceptor = + interceptorWithSleeper(true, 5, 100, 50, 1000, 500, delays::add); interceptor.enqueueOutcome(new ConfigurableStatusException(UNDETERMINED), "ok"); interceptor.invoke(invocationFor("ydbIdempotentRetry")); @@ -303,15 +308,28 @@ void shouldUseFastBackoffForUndeterminedWhenIdempotent() throws Throwable { assertTrue(delays.getFirst() >= 0); } + @Test + void shouldDelayFirstOverloadedRetryUsingZeroBasedRetryIndex() throws Throwable { + List delays = new ArrayList<>(); + TestableInterceptor interceptor = interceptorWithSleeper(true, 5, 1, 1, 1, 1, delays::add); + interceptor.enqueueOutcome(new ConfigurableStatusException(OVERLOADED), "ok"); + + Object result = interceptor.invoke(invocationFor("ydbCustomRetry")); + + assertEquals("ok", result); + assertEquals(2, interceptor.allInvocations()); + assertEquals(List.of(1L), delays); + } + @Test void shouldNotRetryWhenMethodDisablesRetry() { - TestableInterceptor interceptor = interceptorWithConfig(true, 3, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(true, 3, 0, 0, 0, 0); interceptor.enqueueOutcome(new ConfigurableStatusException(BAD_SESSION), "ok"); - ConfigurableStatusException exception = assertThrows( - ConfigurableStatusException.class, - () -> interceptor.invoke(invocationFor("ydbRetryDisabled")) - ); + ConfigurableStatusException exception = + assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("ydbRetryDisabled"))); assertEquals(BAD_SESSION, exception.getStatus().getCode()); assertEquals(1, interceptor.allInvocations()); @@ -319,13 +337,13 @@ void shouldNotRetryWhenMethodDisablesRetry() { @Test void shouldNotRetryWhenGlobalConfigDisablesRetryEvenIfMethodEnablesIt() { - TestableInterceptor interceptor = interceptorWithConfig(false, 3, 0, 0, 0, 0, false); + TestableInterceptor interceptor = interceptorWithConfig(false, 3, 0, 0, 0, 0); interceptor.enqueueOutcome(new ConfigurableStatusException(BAD_SESSION), "ok"); - ConfigurableStatusException exception = assertThrows( - ConfigurableStatusException.class, - () -> interceptor.invoke(invocationFor("ydbRetryEnabled")) - ); + ConfigurableStatusException exception = + assertThrows( + ConfigurableStatusException.class, + () -> interceptor.invoke(invocationFor("ydbRetryEnabled"))); assertEquals(BAD_SESSION, exception.getStatus().getCode()); assertEquals(1, interceptor.allInvocations()); diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/CombinedErrorIntegrationTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/CombinedErrorIntegrationTest.java index c8898984..dde2b544 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/CombinedErrorIntegrationTest.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/CombinedErrorIntegrationTest.java @@ -62,7 +62,8 @@ void shouldStopRetryWhenNonRetryableCommitFollowsRetryableExecuteQuery() { .onError("executeQuery", 1, StatusCode.ABORTED) .onError("commitTransaction", 1, StatusCode.SCHEME_ERROR); - assertThrows(Exception.class, () -> userService.save(createUser(3L, "user3", "first3", "last3"))); + assertThrows( + Exception.class, () -> userService.save(createUser(3L, "user3", "first3", "last3"))); assertEquals(2, DeterministicErrorChannel.getCallCount("executeQuery")); assertEquals(1, DeterministicErrorChannel.getCallCount("commitTransaction")); diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/CommitTransactionRetryTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/CommitTransactionRetryTest.java index 31c4f2f8..a93a1e5f 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/CommitTransactionRetryTest.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/CommitTransactionRetryTest.java @@ -29,10 +29,9 @@ void cleanUp() { } @ParameterizedTest(name = "CommitTransaction") - @EnumSource(value = StatusCode.class, names = { - "ABORTED", "UNAVAILABLE", "OVERLOADED", "BAD_SESSION", - "SESSION_BUSY" - }) + @EnumSource( + value = StatusCode.class, + names = {"ABORTED", "UNAVAILABLE", "OVERLOADED", "BAD_SESSION", "SESSION_BUSY"}) void shouldRecoverFromRetryableCommitError(StatusCode code) { DeterministicErrorChannel.configure().onError("commitTransaction", 1, code); @@ -43,9 +42,9 @@ void shouldRecoverFromRetryableCommitError(StatusCode code) { } @ParameterizedTest(name = "CommitTransaction") - @EnumSource(value = StatusCode.class, names = { - "ABORTED", "UNAVAILABLE" - }) + @EnumSource( + value = StatusCode.class, + names = {"ABORTED", "UNAVAILABLE"}) void shouldRecoverFromMultipleCommitErrors(StatusCode code) { DeterministicErrorChannel.configure() .onError("commitTransaction", 1, code) diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ConcurrentRunner.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ConcurrentRunner.java index 22ee482b..8bdce088 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ConcurrentRunner.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ConcurrentRunner.java @@ -33,15 +33,17 @@ static ConcurrentRunner with(int threadCount) { ConcurrentRunner execute(IntConsumer task) { for (int i = 0; i < threadCount; i++) { final int idx = i; - futures.add(executor.submit(() -> { - try { - barrier.await(); - task.accept(idx); - successCount.incrementAndGet(); - } catch (Throwable t) { - errors.add(t); - } - })); + futures.add( + executor.submit( + () -> { + try { + barrier.await(); + task.accept(idx); + successCount.incrementAndGet(); + } catch (Throwable t) { + errors.add(t); + } + })); } return this; } @@ -57,7 +59,8 @@ ConcurrentResult awaitCompletion(long timeout, TimeUnit unit) throws Exception { record ConcurrentResult(int successCount, List errors) { void assertAllSucceeded() { if (!errors.isEmpty()) { - RuntimeException ex = new RuntimeException("Concurrent test had " + errors.size() + " failures"); + RuntimeException ex = + new RuntimeException("Concurrent test had " + errors.size() + " failures"); errors.forEach(ex::addSuppressed); throw ex; } diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ConcurrentWriteIntegrationTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ConcurrentWriteIntegrationTest.java index 6cd24aee..23e1590c 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ConcurrentWriteIntegrationTest.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ConcurrentWriteIntegrationTest.java @@ -32,8 +32,9 @@ void cleanUp() { @Test void shouldInsertConcurrently() throws Exception { ConcurrentRunner.with(10) - .execute(idx -> userService.save( - new User(1000L + idx, "user" + idx, "first" + idx, "last" + idx))) + .execute( + idx -> + userService.save(new User(1000L + idx, "user" + idx, "first" + idx, "last" + idx))) .awaitCompletion(30, TimeUnit.SECONDS) .assertAllSucceeded(); @@ -47,8 +48,9 @@ void shouldRetryOnConcurrentChannelErrors() throws Exception { DeterministicErrorChannel.configure().onError("commitTransaction", 1, StatusCode.ABORTED); ConcurrentRunner.with(5) - .execute(idx -> userService.save( - new User(200L + idx, "user" + idx, "first" + idx, "last" + idx))) + .execute( + idx -> + userService.save(new User(200L + idx, "user" + idx, "first" + idx, "last" + idx))) .awaitCompletion(30, TimeUnit.SECONDS) .assertAllSucceeded(); } @@ -74,8 +76,9 @@ void shouldInsertConcurrentlyWithRetryErrors() throws Exception { .onError("executeQuery", 2, StatusCode.BAD_SESSION); ConcurrentRunner.with(3) - .execute(idx -> userService.save( - new User(300L + idx, "user" + idx, "first" + idx, "last" + idx))) + .execute( + idx -> + userService.save(new User(300L + idx, "user" + idx, "first" + idx, "last" + idx))) .awaitCompletion(30, TimeUnit.SECONDS) .assertAllSucceeded(); diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DeterministicErrorChannel.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DeterministicErrorChannel.java index 5b54812d..c8bc0b27 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DeterministicErrorChannel.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DeterministicErrorChannel.java @@ -20,7 +20,8 @@ import tech.ydb.proto.StatusCodesProtos; import tech.ydb.proto.query.YdbQuery; -public class DeterministicErrorChannel implements Consumer>, ClientInterceptor { +public class DeterministicErrorChannel + implements Consumer>, ClientInterceptor { private record ErrorRule(String methodName, int callNumber, StatusCode code) { boolean matches(String method, int callNum) { @@ -29,15 +30,20 @@ boolean matches(String method, int callNum) { } private static final List rules = new CopyOnWriteArrayList<>(); - private static final ConcurrentHashMap counters = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap counters = + new ConcurrentHashMap<>(); private static final DeterministicErrorChannel INSTANCE = new DeterministicErrorChannel(); - private static final Map> RESPONSE_BUILDERS = Map.of( - "ExecuteQuery", code -> YdbQuery.ExecuteQueryResponsePart.newBuilder().setStatus(code).build(), - "BeginTransaction", code -> YdbQuery.BeginTransactionResponse.newBuilder().setStatus(code).build(), - "CommitTransaction", code -> YdbQuery.CommitTransactionResponse.newBuilder().setStatus(code).build() - ); + private static final Map> + RESPONSE_BUILDERS = + Map.of( + "ExecuteQuery", + code -> YdbQuery.ExecuteQueryResponsePart.newBuilder().setStatus(code).build(), + "BeginTransaction", + code -> YdbQuery.BeginTransactionResponse.newBuilder().setStatus(code).build(), + "CommitTransaction", + code -> YdbQuery.CommitTransactionResponse.newBuilder().setStatus(code).build()); public DeterministicErrorChannel() { loadFromSystemProperty(); @@ -87,7 +93,8 @@ public ClientCall interceptCall( for (ErrorRule rule : rules) { if (rule.matches(shortName, callNum)) { - Function builderFn = RESPONSE_BUILDERS.get(shortName); + Function builderFn = + RESPONSE_BUILDERS.get(shortName); if (builderFn != null) { StatusCodesProtos.StatusIds.StatusCode protoCode = toProto(rule.code()); RespT errorMsg = (RespT) builderFn.apply(protoCode); @@ -104,9 +111,7 @@ private static StatusCodesProtos.StatusIds.StatusCode toProto(StatusCode code) { return StatusCodesProtos.StatusIds.StatusCode.valueOf(code.name()); } catch (IllegalArgumentException ex) { throw new IllegalArgumentException( - "Status " + code + " is not a YDB protobuf response status. ", - ex - ); + "Status " + code + " is not a YDB protobuf response status. ", ex); } } @@ -119,10 +124,12 @@ private class ErrorCall extends ClientCall { @Override public void start(Listener listener, Metadata headers) { - ForkJoinPool.commonPool().execute(() -> { - listener.onMessage(errorMsg); - listener.onClose(Status.OK, new Metadata()); - }); + ForkJoinPool.commonPool() + .execute( + () -> { + listener.onMessage(errorMsg); + listener.onClose(Status.OK, new Metadata()); + }); } @Override diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DeterministicErrorChannelTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DeterministicErrorChannelTest.java index 47ab3950..31394b3c 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DeterministicErrorChannelTest.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DeterministicErrorChannelTest.java @@ -7,18 +7,19 @@ import static tech.ydb.core.StatusCode.ABORTED; import static tech.ydb.core.StatusCode.CLIENT_CANCELLED; +@YdbIntegrationTest class DeterministicErrorChannelTest { @Test void shouldAcceptProtobufResponseStatus() { - assertDoesNotThrow(() -> DeterministicErrorChannel.configure().onError("executeQuery", 1, ABORTED)); + assertDoesNotThrow( + () -> DeterministicErrorChannel.configure().onError("executeQuery", 1, ABORTED)); } @Test void shouldRejectClientSideStatusAtConfigurationTime() { assertThrows( IllegalArgumentException.class, - () -> DeterministicErrorChannel.configure().onError("executeQuery", 1, CLIENT_CANCELLED) - ); + () -> DeterministicErrorChannel.configure().onError("executeQuery", 1, CLIENT_CANCELLED)); } } diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DisabledRetryIntegrationTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DisabledRetryIntegrationTest.java index 9ac4cca3..0b39b591 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DisabledRetryIntegrationTest.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DisabledRetryIntegrationTest.java @@ -27,23 +27,25 @@ void cleanUp() { } @ParameterizedTest(name = "Retry disabled") - @EnumSource(value = StatusCode.class, names = { - "ABORTED", "UNAVAILABLE", "OVERLOADED" - }) + @EnumSource( + value = StatusCode.class, + names = {"ABORTED", "UNAVAILABLE", "OVERLOADED"}) void shouldNotRetryWhenRetryDisabledExecuteQuery(StatusCode code) { DeterministicErrorChannel.configure().onError("executeQuery", 1, code); - assertThrows(Exception.class, () -> userService.saveRaw(createUser(1L, "user1", "first1", "last1"))); + assertThrows( + Exception.class, () -> userService.saveRaw(createUser(1L, "user1", "first1", "last1"))); } @ParameterizedTest(name = "Retry disabled") - @EnumSource(value = StatusCode.class, names = { - "ABORTED", "UNAVAILABLE", "OVERLOADED" - }) + @EnumSource( + value = StatusCode.class, + names = {"ABORTED", "UNAVAILABLE", "OVERLOADED"}) void shouldNotRetryWhenRetryDisabledCommit(StatusCode code) { DeterministicErrorChannel.configure().onError("commitTransaction", 1, code); - assertThrows(Exception.class, () -> userService.saveRaw(createUser(2L, "user2", "first2", "last2"))); + assertThrows( + Exception.class, () -> userService.saveRaw(createUser(2L, "user2", "first2", "last2"))); } private User createUser(Long id, String username, String firstname, String lastname) { diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ExecuteQueryRetryIntegrationTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ExecuteQueryRetryIntegrationTest.java index e654d4e0..64eb03a8 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ExecuteQueryRetryIntegrationTest.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ExecuteQueryRetryIntegrationTest.java @@ -31,10 +31,9 @@ void cleanUp() { } @ParameterizedTest(name = "ExecuteQuery") - @EnumSource(value = StatusCode.class, names = { - "ABORTED", "UNAVAILABLE", "OVERLOADED", "BAD_SESSION", - "SESSION_BUSY" - }) + @EnumSource( + value = StatusCode.class, + names = {"ABORTED", "UNAVAILABLE", "OVERLOADED", "BAD_SESSION", "SESSION_BUSY"}) void shouldRecoverFromRetryableError(StatusCode code) { DeterministicErrorChannel.configure().onError("executeQuery", 1, code); @@ -45,9 +44,9 @@ void shouldRecoverFromRetryableError(StatusCode code) { } @ParameterizedTest(name = "ExecuteQuery") - @EnumSource(value = StatusCode.class, names = { - "ABORTED", "UNAVAILABLE", "OVERLOADED", "BAD_SESSION" - }) + @EnumSource( + value = StatusCode.class, + names = {"ABORTED", "UNAVAILABLE", "OVERLOADED", "BAD_SESSION"}) void shouldRecoverFromMultipleRetryableErrors(StatusCode code) { DeterministicErrorChannel.configure() .onError("executeQuery", 1, code) @@ -72,13 +71,14 @@ void shouldRecoverFromMixedErrors() { } @ParameterizedTest(name = "ExecuteQuery") - @EnumSource(value = StatusCode.class, names = { - "SCHEME_ERROR", "GENERIC_ERROR", "PRECONDITION_FAILED" - }) + @EnumSource( + value = StatusCode.class, + names = {"SCHEME_ERROR", "GENERIC_ERROR", "PRECONDITION_FAILED"}) void shouldNotRetryNonRetryableError(StatusCode code) { DeterministicErrorChannel.configure().onError("executeQuery", 1, code); - assertThrows(Exception.class, () -> userService.save(createUser(4L, "user4", "first4", "last4"))); + assertThrows( + Exception.class, () -> userService.save(createUser(4L, "user4", "first4", "last4"))); assertEquals(1, DeterministicErrorChannel.getCallCount("executeQuery")); } diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/HappyPathIntegrationTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/HappyPathIntegrationTest.java index 2e67aaee..0d21a015 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/HappyPathIntegrationTest.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/HappyPathIntegrationTest.java @@ -1,5 +1,9 @@ package tech.ydb.retry.integration; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -9,10 +13,6 @@ import tech.ydb.retry.integration.app.UserApplication; import tech.ydb.retry.integration.app.UserService; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; - @SpringBootTest(classes = UserApplication.class) @ActiveProfiles({"enabled", "ydb"}) class HappyPathIntegrationTest extends YdbDockerTest { diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/IdempotentRetryIntegrationTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/IdempotentRetryIntegrationTest.java index 78c77413..4f525ee6 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/IdempotentRetryIntegrationTest.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/IdempotentRetryIntegrationTest.java @@ -31,28 +31,38 @@ void cleanUp() { } @ParameterizedTest(name = "Idempotent executeQuery non-retryable") - @EnumSource(value = StatusCode.class, names = {"TIMEOUT", "SESSION_EXPIRED"}) + @EnumSource( + value = StatusCode.class, + names = {"TIMEOUT", "SESSION_EXPIRED"}) void shouldNotRetryTimeoutOrSessionExpiredWhenIdempotentExecuteQuery(StatusCode code) { DeterministicErrorChannel.configure().onError("executeQuery", 1, code); - assertThrows(Exception.class, () -> userService.saveIdempotent(createUser(1L, "user1", "first1", "last1"))); + assertThrows( + Exception.class, + () -> userService.saveIdempotent(createUser(1L, "user1", "first1", "last1"))); assertEquals(1, DeterministicErrorChannel.getCallCount("executeQuery")); assertNull(userService.findById(1L)); } @ParameterizedTest(name = "Non-idempotent executeQuery") - @EnumSource(value = StatusCode.class, names = {"TIMEOUT", "SESSION_EXPIRED", "UNDETERMINED"}) - void shouldNotRetryUndeterminedOrNonRetryableStatusWhenNotIdempotentExecuteQuery(StatusCode code) { + @EnumSource( + value = StatusCode.class, + names = {"TIMEOUT", "SESSION_EXPIRED", "UNDETERMINED"}) + void shouldNotRetryUndeterminedOrNonRetryableStatusWhenNotIdempotentExecuteQuery( + StatusCode code) { DeterministicErrorChannel.configure().onError("executeQuery", 1, code); - assertThrows(Exception.class, () -> userService.save(createUser(2L, "user2", "first2", "last2"))); + assertThrows( + Exception.class, () -> userService.save(createUser(2L, "user2", "first2", "last2"))); assertEquals(1, DeterministicErrorChannel.getCallCount("executeQuery")); assertNull(userService.findById(2L)); } @ParameterizedTest(name = "Idempotent executeQuery") - @EnumSource(value = StatusCode.class, names = {"UNDETERMINED"}) + @EnumSource( + value = StatusCode.class, + names = {"UNDETERMINED"}) void shouldRetryUndeterminedWhenIdempotentExecuteQuery(StatusCode code) { DeterministicErrorChannel.configure().onError("executeQuery", 1, code); @@ -63,17 +73,23 @@ void shouldRetryUndeterminedWhenIdempotentExecuteQuery(StatusCode code) { } @ParameterizedTest(name = "Idempotent commit non-retryable") - @EnumSource(value = StatusCode.class, names = {"TIMEOUT", "SESSION_EXPIRED"}) + @EnumSource( + value = StatusCode.class, + names = {"TIMEOUT", "SESSION_EXPIRED"}) void shouldNotRetryTimeoutOrSessionExpiredWhenIdempotentCommit(StatusCode code) { DeterministicErrorChannel.configure().onError("commitTransaction", 1, code); - assertThrows(Exception.class, () -> userService.saveIdempotent(createUser(4L, "user4", "first4", "last4"))); + assertThrows( + Exception.class, + () -> userService.saveIdempotent(createUser(4L, "user4", "first4", "last4"))); assertEquals(1, DeterministicErrorChannel.getCallCount("commitTransaction")); } @ParameterizedTest(name = "Idempotent commit") - @EnumSource(value = StatusCode.class, names = {"UNDETERMINED"}) + @EnumSource( + value = StatusCode.class, + names = {"UNDETERMINED"}) void shouldRetryUndeterminedWhenIdempotentCommit(StatusCode code) { DeterministicErrorChannel.configure().onError("commitTransaction", 1, code); @@ -84,11 +100,14 @@ void shouldRetryUndeterminedWhenIdempotentCommit(StatusCode code) { } @ParameterizedTest(name = "Non-idempotent commit") - @EnumSource(value = StatusCode.class, names = {"UNDETERMINED"}) + @EnumSource( + value = StatusCode.class, + names = {"UNDETERMINED"}) void shouldNotRetryUndeterminedWhenNotIdempotentCommit(StatusCode code) { DeterministicErrorChannel.configure().onError("commitTransaction", 1, code); - assertThrows(Exception.class, () -> userService.save(createUser(6L, "user6", "first6", "last6"))); + assertThrows( + Exception.class, () -> userService.save(createUser(6L, "user6", "first6", "last6"))); assertEquals(1, DeterministicErrorChannel.getCallCount("commitTransaction")); } diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/IntegrationEnvironmentTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/IntegrationEnvironmentTest.java index db135f79..d52b4047 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/IntegrationEnvironmentTest.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/IntegrationEnvironmentTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; +@YdbIntegrationTest class IntegrationEnvironmentTest { @Test @@ -13,8 +14,7 @@ void dockerShouldBeAvailableForIntegrationTests() { try { assertTrue( DockerClientFactory.instance().isDockerAvailable(), - "Docker/Testcontainers must be available for integration tests" - ); + "Docker/Testcontainers must be available for integration tests"); } catch (Throwable throwable) { fail("Docker/Testcontainers must be available for integration tests", throwable); } diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/MaxRetriesExhaustedTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/MaxRetriesExhaustedTest.java index e86196d6..4e1510be 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/MaxRetriesExhaustedTest.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/MaxRetriesExhaustedTest.java @@ -36,7 +36,8 @@ void shouldExhaustMaxRetriesAndThrow() { .onError("executeQuery", 3, StatusCode.ABORTED) .onError("executeQuery", 4, StatusCode.ABORTED); - assertThrows(Exception.class, + assertThrows( + Exception.class, () -> userService.saveWithMaxRetries3(createUser(1L, "user1", "first1", "last1"))); assertEquals(4, DeterministicErrorChannel.getCallCount("executeQuery")); } diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/NonRetryableCommitIntegrationTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/NonRetryableCommitIntegrationTest.java index 6e21a355..a5d83401 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/NonRetryableCommitIntegrationTest.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/NonRetryableCommitIntegrationTest.java @@ -30,25 +30,28 @@ void cleanUp() { } @ParameterizedTest(name = "NonRetryableCommit") - @EnumSource(value = StatusCode.class, names = { - "SCHEME_ERROR", "GENERIC_ERROR", "PRECONDITION_FAILED", "UNAUTHORIZED" - }) + @EnumSource( + value = StatusCode.class, + names = {"SCHEME_ERROR", "GENERIC_ERROR", "PRECONDITION_FAILED", "UNAUTHORIZED"}) void shouldNotRetryNonRetryableCommitError(StatusCode code) { DeterministicErrorChannel.configure().onError("commitTransaction", 1, code); - assertThrows(Exception.class, () -> userService.save(createUser(1L, "user1", "first1", "last1"))); + assertThrows( + Exception.class, () -> userService.save(createUser(1L, "user1", "first1", "last1"))); assertEquals(1, DeterministicErrorChannel.getCallCount("commitTransaction")); assertNull(userService.findById(1L)); } @ParameterizedTest(name = "NonRetryableCommit") - @EnumSource(value = StatusCode.class, names = { - "SCHEME_ERROR", "GENERIC_ERROR", "PRECONDITION_FAILED", "UNAUTHORIZED" - }) + @EnumSource( + value = StatusCode.class, + names = {"SCHEME_ERROR", "GENERIC_ERROR", "PRECONDITION_FAILED", "UNAUTHORIZED"}) void shouldNotRetryNonRetryableCommitErrorWithYdbTransactional(StatusCode code) { DeterministicErrorChannel.configure().onError("commitTransaction", 1, code); - assertThrows(Exception.class, () -> userService.saveWithMaxRetries3(createUser(2L, "user2", "first2", "last2"))); + assertThrows( + Exception.class, + () -> userService.saveWithMaxRetries3(createUser(2L, "user2", "first2", "last2"))); assertEquals(1, DeterministicErrorChannel.getCallCount("commitTransaction")); assertNull(userService.findById(2L)); } diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/YdbDockerTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/YdbDockerTest.java index 3a35c963..fd5c9bd3 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/YdbDockerTest.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/YdbDockerTest.java @@ -2,12 +2,22 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import tech.ydb.test.junit5.YdbHelperExtension; +/** + * Integration tests use a single YDB environment and a deterministic error channel state, so they + * must be performed sequentially one after the other. + */ +@YdbIntegrationTest +@Execution(ExecutionMode.SAME_THREAD) public abstract class YdbDockerTest { + public static final String INTEGRATION_TEST_LOCK = "ydb-integration-tests"; + @RegisterExtension static final YdbHelperExtension ydb = new YdbHelperExtension(); @@ -18,11 +28,14 @@ static void resetErrorChannel() { @DynamicPropertySource static void propertySource(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", () -> - "jdbc:ydb:" + (ydb.useTls() ? "grpcs://" : "grpc://") + - ydb.endpoint() + ydb.database() - + "?channelInitializer=tech.ydb.retry.integration.DeterministicErrorChannel&" - + (ydb.authToken() != null ? "token=" + ydb.authToken() : "") - ); + registry.add( + "spring.datasource.url", + () -> + "jdbc:ydb:" + + (ydb.useTls() ? "grpcs://" : "grpc://") + + ydb.endpoint() + + ydb.database() + + "?channelInitializer=tech.ydb.retry.integration.DeterministicErrorChannel&" + + (ydb.authToken() != null ? "token=" + ydb.authToken() : "")); } } diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/YdbIntegrationTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/YdbIntegrationTest.java new file mode 100644 index 00000000..0eea1f8f --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/YdbIntegrationTest.java @@ -0,0 +1,17 @@ +package tech.ydb.retry.integration; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.parallel.ResourceLock; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Tag("integration") +@ResourceLock(YdbDockerTest.INTEGRATION_TEST_LOCK) +public @interface YdbIntegrationTest { +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/app/UserService.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/app/UserService.java index 325fa2e5..b493645a 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/app/UserService.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/app/UserService.java @@ -28,12 +28,12 @@ public void saveWithMaxRetries3(User user) { userRepository.save(user); } - @YdbTransactional(idempotent = 1) + @YdbTransactional(idempotent = true) public void saveIdempotent(User user) { userRepository.save(user); } - @YdbTransactional(maxRetries = 50, idempotent = 1) + @YdbTransactional(maxRetries = 50, idempotent = true) public void updateFirstname(Long id, String firstname) { userRepository.findById(id); userRepository.updateFirstnameById(id, firstname); diff --git a/spring-ydb/spring-ydb-retry/src/test/resources/application-enabled.properties b/spring-ydb/spring-ydb-retry/src/test/resources/application-enabled.properties index 5dc05df7..378bc968 100644 --- a/spring-ydb/spring-ydb-retry/src/test/resources/application-enabled.properties +++ b/spring-ydb/spring-ydb-retry/src/test/resources/application-enabled.properties @@ -7,7 +7,6 @@ ydb.transaction.retry.slow-backoff-base-ms=50 ydb.transaction.retry.fast-backoff-base-ms=5 ydb.transaction.retry.slow-cap-backoff-ms=5000 ydb.transaction.retry.fast-cap-backoff-ms=500 -ydb.transaction.retry.idempotent=false logging.level.org.springframework.jdbc.core.JdbcTemplate=debug logging.level.tech.ydb.retry=debug \ No newline at end of file From a7289d42a7cdf7c28d8356940d33bc92bdfef7a5 Mon Sep 17 00:00:00 2001 From: karambo3a Date: Mon, 11 May 2026 14:44:45 +0300 Subject: [PATCH 3/7] move spring-data-jdbc-ydb to test scope --- spring-ydb/spring-ydb-retry/pom.xml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/spring-ydb/spring-ydb-retry/pom.xml b/spring-ydb/spring-ydb-retry/pom.xml index 9e64562d..7263d6f0 100644 --- a/spring-ydb/spring-ydb-retry/pom.xml +++ b/spring-ydb/spring-ydb-retry/pom.xml @@ -18,11 +18,6 @@ Spring retry module for YDB - - tech.ydb.dialects - spring-data-jdbc-ydb - 1.1.0 - org.springframework.boot spring-boot-autoconfigure @@ -61,13 +56,18 @@ ${spring.version} provided - tech.ydb.jdbc ydb-jdbc-driver 2.3.22 provided + + tech.ydb.dialects + spring-data-jdbc-ydb + 1.1.0 + test + org.junit.jupiter junit-jupiter From cdf8cd0e3513677d307aa0dcd5c07110d0b1f54a Mon Sep 17 00:00:00 2001 From: karambo3a Date: Tue, 12 May 2026 20:41:23 +0300 Subject: [PATCH 4/7] change retry auto-configuration --- .../tech/ydb/retry/YdbTransactionAutoConfiguration.java | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionAutoConfiguration.java b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionAutoConfiguration.java index e04e0ddc..f226f6a9 100644 --- a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionAutoConfiguration.java +++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionAutoConfiguration.java @@ -2,17 +2,14 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; import org.springframework.transaction.interceptor.TransactionInterceptor; -@Configuration -@AutoConfigureBefore(TransactionAutoConfiguration.class) +@AutoConfiguration @ConditionalOnClass(TransactionInterceptor.class) @EnableConfigurationProperties(YdbRetryProperties.class) public class YdbTransactionAutoConfiguration { From d62966c935d80c41d7e2286ee7feb4990501337e Mon Sep 17 00:00:00 2001 From: karambo3a Date: Sun, 24 May 2026 00:34:40 +0300 Subject: [PATCH 5/7] fix after review --- .../ydb/retry/YdbTransactionInterceptor.java | 44 +++++++++++++--- .../YdbTransactionInterceptorFactory.java | 17 ++++++ .../YdbTransactionInterceptorReplacer.java | 2 +- .../ydb/retry/InterceptorTestSupport.java | 11 +++- .../YdbTransactionInterceptorFactoryTest.java | 22 +++++++- ...bTransactionInterceptorInvocationTest.java | 52 +++++++++++++++++++ .../YdbTransactionalConfigOverrideTest.java | 25 +++++++++ .../retry/integration/ConcurrentRunner.java | 21 ++++++-- 8 files changed, 178 insertions(+), 16 deletions(-) create mode 100644 spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorInvocationTest.java diff --git a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptor.java b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptor.java index c9a233a3..480b432a 100644 --- a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptor.java +++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptor.java @@ -1,7 +1,9 @@ package tech.ydb.retry; +import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Method; import org.aopalliance.intercept.MethodInvocation; +import org.springframework.aop.ProxyMethodInvocation; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.aop.support.AopUtils; @@ -69,7 +71,9 @@ private Object invokeWithinTransactionWithRetryContext( throws Throwable { for (int attempt = 0; ; attempt++) { try { - return this.invokeWithinTransaction(invocation.getMethod(), targetClass, createCallback(invocation)); + MethodInvocation cloneInvocation = cloneInvocation(invocation); + return this.invokeWithinTransaction( + invocation.getMethod(), targetClass, createCallback(cloneInvocation)); } catch (Throwable ex) { if (ex instanceof Error) { throw ex; @@ -113,15 +117,32 @@ private YdbTransactional resolveYdbTransactionAnnotation( Method method, @Nullable Class targetClass) { Method specificMethod = targetClass != null ? AopUtils.getMostSpecificMethod(method, targetClass) : method; - YdbTransactional methodLevel = - AnnotatedElementUtils.findMergedAnnotation(specificMethod, YdbTransactional.class); - if (methodLevel != null) { - return methodLevel; + + YdbTransactional annotation = findYdbTransactional(specificMethod); + if (annotation != null) { + return annotation; } - if (targetClass != null) { - return AnnotatedElementUtils.findMergedAnnotation(targetClass, YdbTransactional.class); + + annotation = findYdbTransactional(targetClass); + if (annotation != null) { + return annotation; } - return null; + + if (!specificMethod.equals(method)) { + annotation = findYdbTransactional(method); + if (annotation != null) { + return annotation; + } + } + + return findYdbTransactional(method.getDeclaringClass()); + } + + @Nullable + private YdbTransactional findYdbTransactional(@Nullable AnnotatedElement element) { + return element != null + ? AnnotatedElementUtils.findMergedAnnotation(element, YdbTransactional.class) + : null; } @Nullable @@ -152,4 +173,11 @@ public Object[] getArguments() { } }; } + + private MethodInvocation cloneInvocation(MethodInvocation invocation) { + if (invocation instanceof ProxyMethodInvocation proxyMethodInvocation) { + return proxyMethodInvocation.invocableClone(); + } + return invocation; + } } diff --git a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorFactory.java b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorFactory.java index 02d73dd0..7f175300 100644 --- a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorFactory.java +++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorFactory.java @@ -33,6 +33,9 @@ public void setBeanFactory(BeanFactory beanFactory) throws BeansException { @Override public YdbTransactionInterceptor getObject() { + requireRetryProperties(); + requireTransactionAttributeSource(); + YdbTransactionInterceptor interceptor = new YdbTransactionInterceptor(retryProperties.toConfig(), Thread::sleep); interceptor.setTransactionAttributeSource(transactionAttributeSource); if (beanFactory != null) { @@ -47,6 +50,20 @@ public YdbTransactionInterceptor getObject() { return interceptor; } + private void requireRetryProperties() { + if (retryProperties == null) { + throw new IllegalStateException( + "retryProperties must be set before creating YdbTransactionInterceptor"); + } + } + + private void requireTransactionAttributeSource() { + if (transactionAttributeSource == null) { + throw new IllegalStateException( + "transactionAttributeSource must be set before creating YdbTransactionInterceptor"); + } + } + @Nullable private TransactionManager resolveTransactionManager() { if (beanFactory == null) { diff --git a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorReplacer.java b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorReplacer.java index 13d88cfe..c88ff316 100644 --- a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorReplacer.java +++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorReplacer.java @@ -39,7 +39,7 @@ public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) registry.removeBeanDefinition(TRANSACTION_INTERCEPTOR_BEAN_NAME); registry.registerBeanDefinition(TRANSACTION_INTERCEPTOR_BEAN_NAME, newBd); - log.info( + log.debug( "registered YdbTransactionInterceptorFactory as bean '{}'", TRANSACTION_INTERCEPTOR_BEAN_NAME); } diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/InterceptorTestSupport.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/InterceptorTestSupport.java index 8b109043..5f7347c7 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/InterceptorTestSupport.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/InterceptorTestSupport.java @@ -46,9 +46,13 @@ static TestableInterceptor interceptorWithSleeper( } static MethodInvocation invocationFor(String methodName) { - MethodInvocation invocation = Mockito.mock(MethodInvocation.class); Method method = methodOf(methodName); Object target = targetFor(methodName); + return invocationFor(method, target); + } + + static MethodInvocation invocationFor(Method method, Object target) { + MethodInvocation invocation = Mockito.mock(MethodInvocation.class); Mockito.when(invocation.getMethod()).thenReturn(method); Mockito.when(invocation.getThis()).thenReturn(target); Mockito.when(invocation.getArguments()).thenReturn(new Object[0]); @@ -98,6 +102,11 @@ int retries() { @Override protected Object invokeWithinTransaction( Method method, Class targetClass, InvocationCallback invocation) throws Throwable { + try { + invocation.proceedWithInvocation(); + } catch (Throwable ignored) { + + } attempts.incrementAndGet(); Object result = outcomes.removeFirst(); if (result instanceof Throwable throwable) { diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorFactoryTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorFactoryTest.java index b03385d7..be28d530 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorFactoryTest.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorFactoryTest.java @@ -42,11 +42,29 @@ void getObjectShouldUseRetryPropertiesConfig() { } @Test - void getObjectShouldThrowNpeWhenRetryPropertiesIsNull() { + void getObjectShouldThrowIllegalStateWhenRetryPropertiesIsNull() { YdbTransactionInterceptorFactory factory = new YdbTransactionInterceptorFactory(); factory.setTransactionAttributeSource(new AnnotationTransactionAttributeSource()); - assertThrows(NullPointerException.class, factory::getObject); + IllegalStateException exception = + assertThrows(IllegalStateException.class, factory::getObject); + + assertEquals( + "retryProperties must be set before creating YdbTransactionInterceptor", + exception.getMessage()); + } + + @Test + void getObjectShouldThrowIllegalStateWhenTransactionAttributeSourceIsNull() { + YdbTransactionInterceptorFactory factory = new YdbTransactionInterceptorFactory(); + factory.setRetryProperties(new YdbRetryProperties()); + + IllegalStateException exception = + assertThrows(IllegalStateException.class, factory::getObject); + + assertEquals( + "transactionAttributeSource must be set before creating YdbTransactionInterceptor", + exception.getMessage()); } @Test diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorInvocationTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorInvocationTest.java new file mode 100644 index 00000000..c67a6b1d --- /dev/null +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorInvocationTest.java @@ -0,0 +1,52 @@ +package tech.ydb.retry; + +import java.lang.reflect.Method; +import org.aopalliance.intercept.MethodInvocation; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.aop.ProxyMethodInvocation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static tech.ydb.core.StatusCode.BAD_SESSION; + +class YdbTransactionInterceptorInvocationTest extends InterceptorTestSupport { + + @Test + void shouldCloneProxyMethodInvocationForEachRetryAttempt() throws Throwable { + TestableInterceptor interceptor = interceptorWithConfig(true, 3, 0, 0, 0, 0); + + Method method = methodOf("ydbCustomRetry"); + Object target = new YdbTransactionalTestService(); + + ProxyMethodInvocation invocation = Mockito.mock(ProxyMethodInvocation.class); + MethodInvocation firstAttempt = Mockito.mock(MethodInvocation.class); + MethodInvocation secondAttempt = Mockito.mock(MethodInvocation.class); + + stubInvocationMetadata(invocation, method, target); + stubInvocationMetadata(firstAttempt, method, target); + stubInvocationMetadata(secondAttempt, method, target); + + Mockito.when(invocation.proceed()) + .thenThrow(new AssertionError("original invocation must not be proceeded directly")); + Mockito.when(invocation.invocableClone()).thenReturn(firstAttempt, secondAttempt); + Mockito.when(firstAttempt.proceed()).thenThrow(new ConfigurableStatusException(BAD_SESSION)); + Mockito.when(secondAttempt.proceed()).thenReturn("ok"); + + interceptor.enqueueOutcome( + new ConfigurableStatusException(BAD_SESSION), "ok"); + Object result = interceptor.invoke(invocation); + + assertEquals("ok", result); + assertEquals(2, interceptor.allInvocations()); + Mockito.verify(invocation, Mockito.times(2)).invocableClone(); + Mockito.verify(invocation, Mockito.never()).proceed(); + Mockito.verify(firstAttempt).proceed(); + Mockito.verify(secondAttempt).proceed(); + } + + private static void stubInvocationMetadata(MethodInvocation invocation, Method method, Object target) { + Mockito.when(invocation.getMethod()).thenReturn(method); + Mockito.when(invocation.getThis()).thenReturn(target); + Mockito.when(invocation.getArguments()).thenReturn(new Object[0]); + } +} diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionalConfigOverrideTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionalConfigOverrideTest.java index 8db06f20..11d963a5 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionalConfigOverrideTest.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionalConfigOverrideTest.java @@ -135,6 +135,19 @@ void shouldRetryClientCancelledWhenIdempotent() throws Throwable { assertEquals(2, interceptor.allInvocations()); } + @Test + void shouldUseInterfaceMethodYdbTransactionalOverrides() throws Throwable { + TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0); + interceptor.enqueueOutcome(new ConfigurableStatusException(CLIENT_CANCELLED), "ok"); + + Object result = interceptor.invoke(invocationFor( + InterfaceAnnotatedService.class.getMethod("interfaceAnnotatedIdempotentRetry"), + new InterfaceAnnotatedServiceImpl())); + + assertEquals("ok", result); + assertEquals(2, interceptor.allInvocations()); + } + @Test void shouldNotRetryTransportUnavailableWhenNotIdempotent() { TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0); @@ -348,4 +361,16 @@ void shouldNotRetryWhenGlobalConfigDisablesRetryEvenIfMethodEnablesIt() { assertEquals(BAD_SESSION, exception.getStatus().getCode()); assertEquals(1, interceptor.allInvocations()); } + + interface InterfaceAnnotatedService { + @YdbTransactional(maxRetries = 2, idempotent = true) + String interfaceAnnotatedIdempotentRetry(); + } + + static final class InterfaceAnnotatedServiceImpl implements InterfaceAnnotatedService { + @Override + public String interfaceAnnotatedIdempotentRetry() { + return "ok"; + } + } } diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ConcurrentRunner.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ConcurrentRunner.java index 8bdce088..33c6de77 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ConcurrentRunner.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ConcurrentRunner.java @@ -49,11 +49,24 @@ ConcurrentRunner execute(IntConsumer task) { } ConcurrentResult awaitCompletion(long timeout, TimeUnit unit) throws Exception { - for (Future f : futures) { - f.get(timeout, unit); + boolean completed = false; + try { + for (Future f : futures) { + f.get(timeout, unit); + } + completed = true; + return new ConcurrentResult(successCount.get(), errors); + } finally { + if (completed) { + executor.shutdown(); + } else { + executor.shutdownNow(); + } } - executor.shutdown(); - return new ConcurrentResult(successCount.get(), errors); + } + + boolean isShutdown() { + return executor.isShutdown(); } record ConcurrentResult(int successCount, List errors) { From 78d35e102a39bb06b0b5b0bc85ff254a39370335 Mon Sep 17 00:00:00 2001 From: karambo3a Date: Sun, 24 May 2026 00:38:16 +0300 Subject: [PATCH 6/7] add github workflows --- .github/workflows/ci-spring-ydb.yaml | 76 +++++++++++++++ .github/workflows/publish-spring-ydb.yaml | 82 ++++++++++++++++ spring-ydb/pom.xml | 112 +++++++++++++++++++++- spring-ydb/spring-ydb-retry/pom.xml | 34 ++++--- 4 files changed, 281 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/ci-spring-ydb.yaml create mode 100644 .github/workflows/publish-spring-ydb.yaml diff --git a/.github/workflows/ci-spring-ydb.yaml b/.github/workflows/ci-spring-ydb.yaml new file mode 100644 index 00000000..318109db --- /dev/null +++ b/.github/workflows/ci-spring-ydb.yaml @@ -0,0 +1,76 @@ +name: Spring YDB CI with Maven + +on: + push: + paths: + - 'spring-ydb/**' + branches: + - main + pull_request: + paths: + - 'spring-ydb/**' + +env: + MAVEN_ARGS: --batch-mode --update-snapshots -Dstyle.color=always + +jobs: + prepare: + name: Prepare Maven cache + runs-on: ubuntu-24.04 + + env: + MAVEN_ARGS: --batch-mode -Dstyle.color=always + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'maven' + + - name: Download dependencies (Default) + working-directory: ./spring-ydb + run: mvn $MAVEN_ARGS dependency:resolve-plugins dependency:go-offline + + - name: Download dependencies (Spring Boot 3) + working-directory: ./spring-ydb + run: mvn $MAVEN_ARGS -Pspring-boot3 dependency:resolve-plugins dependency:go-offline + + - name: Download dependencies (Spring Boot 4) + working-directory: ./spring-ydb + run: mvn $MAVEN_ARGS -Pspring-boot4 dependency:resolve-plugins dependency:go-offline + + build: + name: Spring YDB build & tests + runs-on: ubuntu-24.04 + needs: prepare + + strategy: + matrix: + java: [ '17', '21', '24' ] + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK ${{matrix.java}} + uses: actions/setup-java@v4 + with: + java-version: ${{matrix.java}} + distribution: 'temurin' + cache: maven + + - name: Build spring-ydb + working-directory: ./spring-ydb + run: mvn $MAVEN_ARGS package + + - name: Tests with Spring Boot 3 + working-directory: ./spring-ydb + run: mvn $MAVEN_ARGS -Pspring-boot3 test + + - name: Tests with Spring Boot 4 + working-directory: ./spring-ydb + run: mvn $MAVEN_ARGS -Pspring-boot4 test diff --git a/.github/workflows/publish-spring-ydb.yaml b/.github/workflows/publish-spring-ydb.yaml new file mode 100644 index 00000000..d64cf870 --- /dev/null +++ b/.github/workflows/publish-spring-ydb.yaml @@ -0,0 +1,82 @@ +name: Publish Spring YDB + +on: + push: + tags: + - 'spring-ydb/v*.*.*' + +env: + MAVEN_ARGS: --batch-mode --no-transfer-progress -Dstyle.color=always + +jobs: + validate: + name: Validate Spring YDB + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v4 + + - name: Extract spring-ydb version + run: | + cd spring-ydb + SPRING_YDB_VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout) + echo "SPRING_YDB_VERSION=$SPRING_YDB_VERSION" >> "$GITHUB_ENV" + + - name: Fail workflow if version is snapshot + if: endsWith(env.SPRING_YDB_VERSION, 'SNAPSHOT') + uses: actions/github-script@v6 + with: + script: core.setFailed('SNAPSHOT version cannot be published') + + - name: Fail workflow if version is not equal to tag name + if: format('spring-ydb/v{0}', env.SPRING_YDB_VERSION) != github.ref_name + uses: actions/github-script@v6 + with: + script: core.setFailed('Release name must be equal to project version') + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'temurin' + cache: 'maven' + + - name: Download dependencies + working-directory: ./spring-ydb + run: mvn $MAVEN_ARGS -Pspring-boot-minimal dependency:go-offline + + - name: Build with Maven + working-directory: ./spring-ydb + run: mvn $MAVEN_ARGS -Pspring-boot-minimal package + + publish: + name: Publish Spring YDB + runs-on: ubuntu-latest + needs: validate + + steps: + - name: Install gpg secret key + run: | + # Install gpg secret key + cat <(echo -e "${{ secrets.MAVEN_OSSRH_GPG_SECRET_KEY }}") | gpg --batch --import + # Verify gpg secret key + gpg --list-secret-keys --keyid-format LONG + + - uses: actions/checkout@v4 + + - name: Set up Maven Central Repository + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'temurin' + cache: 'maven' + server-id: ossrh-s01 + server-username: MAVEN_USERNAME + server-password: MAVEN_PASSWORD + + - name: Publish package + working-directory: ./spring-ydb + run: mvn $MAVEN_ARGS -Possrh-s01,spring-boot-minimal -Dgpg.passphrase=${{ secrets.MAVEN_OSSRH_GPG_PASSWORD }} clean deploy + env: + MAVEN_USERNAME: ${{ secrets.MAVEN_OSSRH_USERNAME }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_OSSRH_TOKEN }} diff --git a/spring-ydb/pom.xml b/spring-ydb/pom.xml index 87a2e508..a4e80020 100644 --- a/spring-ydb/pom.xml +++ b/spring-ydb/pom.xml @@ -8,21 +8,54 @@ tech.ydb spring-ydb 1.0.0-SNAPSHOT - pom + Spring YDB Spring integration modules for YDB + https://github.com/ydb-platform/ydb-java-dialects + + pom + + + + Ekaterina Isaeva + ikaterina0909@gmail.com + YDB + https://ydb.tech/ + + + Kirill Kurdyukov + kurdyukov-kir@ydb.tech + YDB + https://ydb.tech/ + + + + + https://github.com/ydb-platform/ydb-java-dialects + scm:git:https://github.com/ydb-platform/ydb-java-dialects.git + scm:git:https://github.com/ydb-platform/ydb-java-dialects.git + + + + + Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0 + + spring-ydb-retry - 21 - 21 - 21 + 17 + 17 + 17 + 17 UTF-8 6.2.0 3.4.0 + 2.3.22 @@ -34,7 +67,76 @@ pom import + + tech.ydb.jdbc + ydb-jdbc-driver + ${ydb-jdbc.version} + - \ No newline at end of file + + + spring-boot-minimal + + true + + + 3.4.0 + + + + spring-boot3 + + 3.5.7 + + + + spring-boot4 + + 4.0.0 + + + + ossrh-s01 + + false + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.7 + + + sign-artifacts + verify + + sign + + + + + + --pinentry-mode + loopback + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.7.0 + true + + ossrh-s01 + + + + + + + + diff --git a/spring-ydb/spring-ydb-retry/pom.xml b/spring-ydb/spring-ydb-retry/pom.xml index 7263d6f0..26ebd729 100644 --- a/spring-ydb/spring-ydb-retry/pom.xml +++ b/spring-ydb/spring-ydb-retry/pom.xml @@ -21,45 +21,28 @@ org.springframework.boot spring-boot-autoconfigure - ${spring-boot.version} provided true org.springframework.boot spring-boot - ${spring-boot.version} provided true org.springframework spring-tx - ${spring.version} - provided - - - org.springframework - spring-context - ${spring.version} - provided - - - org.springframework - spring-aop - ${spring.version} provided org.springframework spring-core - ${spring.version} provided tech.ydb.jdbc ydb-jdbc-driver - 2.3.22 provided @@ -83,7 +66,6 @@ org.springframework spring-test - ${spring.version} test @@ -122,6 +104,22 @@ + + org.apache.maven.plugins + maven-javadoc-plugin + 3.5.0 + + 17 + + + + attach-javadocs + + jar + + + + org.apache.maven.plugins maven-jar-plugin From 4a0f00019f8baa5fb14fd9f185d77b4e344ca70a Mon Sep 17 00:00:00 2001 From: karambo3a Date: Mon, 25 May 2026 23:53:43 +0300 Subject: [PATCH 7/7] fix ci --- spring-ydb/spring-ydb-retry/README.md | 2 +- spring-ydb/spring-ydb-retry/slo/pom.xml | 2 +- .../java/tech/ydb/retry/YdbTransactionalConfigOverrideTest.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/spring-ydb/spring-ydb-retry/README.md b/spring-ydb/spring-ydb-retry/README.md index 67141d45..e2fc576d 100644 --- a/spring-ydb/spring-ydb-retry/README.md +++ b/spring-ydb/spring-ydb-retry/README.md @@ -17,7 +17,7 @@ for transactional operations with [YDB](https://ydb.tech). ### Requirements -- Java 21 or above +- Java 17 or above - Spring Boot 3.4+ / Spring Framework 6.2+ - [YDB JDBC Driver](https://github.com/ydb-platform/ydb-jdbc-driver) - Access to a YDB Database instance diff --git a/spring-ydb/spring-ydb-retry/slo/pom.xml b/spring-ydb/spring-ydb-retry/slo/pom.xml index eed68110..0641d0fd 100644 --- a/spring-ydb/spring-ydb-retry/slo/pom.xml +++ b/spring-ydb/spring-ydb-retry/slo/pom.xml @@ -20,7 +20,7 @@ YDB SLO Workload - 21 + 17 UTF-8 1.43.0 diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionalConfigOverrideTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionalConfigOverrideTest.java index 11d963a5..4a7aea7d 100644 --- a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionalConfigOverrideTest.java +++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionalConfigOverrideTest.java @@ -318,7 +318,7 @@ void shouldUseFastBackoffForUndeterminedWhenIdempotent() throws Throwable { interceptor.invoke(invocationFor("ydbIdempotentRetry")); assertEquals(1, delays.size()); - assertTrue(delays.getFirst() >= 0); + assertTrue(delays.get(0) >= 0); } @Test