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
new file mode 100644
index 00000000..34fdbddb
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/slo/src/main/resources/application.properties
@@ -0,0 +1,23 @@
+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}
+
+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..e90078d4
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbDelayCalculator.java
@@ -0,0 +1,47 @@
+package tech.ydb.retry;
+
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * Backoff math, ported one-to-one from {@code kotlin-exposed-dialect}'s {@code YdbRetryPolicy.kt}.
+ *
+ * {@link #calculateBackoffMillis} computes the un-jittered backoff window as
+ * {@code min(baseMs * 2^min(ceiling, attempt), capMs)}; {@link #fullJitterMillis} and
+ * {@link #equalJitterMillis} then apply jitter on top of that window.
+ */
+public final class YdbDelayCalculator {
+
+ private YdbDelayCalculator() {
+ }
+
+ /**
+ * Pre-jitter backoff window: {@code min(baseMs * 2^min(ceiling, attempt), capMs)}.
+ */
+ public static int calculateBackoffMillis(int baseMs, int capMs, int ceiling, int attempt) {
+ int shift = Math.min(ceiling, attempt);
+ long scaled = (long) baseMs << shift;
+ return (int) Math.min(scaled, capMs);
+ }
+
+ /** Full jitter: uniform in {@code [0, calculatedBackoff]}. */
+ public static long fullJitterMillis(int baseMs, int capMs, int ceiling, int attempt) {
+ int calculatedBackoff = calculateBackoffMillis(baseMs, capMs, ceiling, attempt);
+ return randomLong(calculatedBackoff + 1L);
+ }
+
+ /**
+ * Equal jitter: {@code calculatedBackoff/2 + calculatedBackoff%2 + random(0..calculatedBackoff/2)}.
+ */
+ public static long equalJitterMillis(int baseMs, int capMs, int ceiling, int attempt) {
+ int calculatedBackoff = calculateBackoffMillis(baseMs, capMs, ceiling, attempt);
+ int temp = calculatedBackoff / 2;
+ return temp + calculatedBackoff % 2 + randomLong(temp + 1L);
+ }
+
+ private static long randomLong(long boundExclusive) {
+ if (boundExclusive <= 0) {
+ return 0L;
+ }
+ return ThreadLocalRandom.current().nextLong(boundExclusive);
+ }
+}
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..dcd7c505
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbRetryPolicy.java
@@ -0,0 +1,112 @@
+package tech.ydb.retry;
+
+import java.sql.SQLException;
+import java.util.OptionalLong;
+import java.util.Set;
+
+/**
+ * Decides whether a YDB error should be retried and how long to wait before the next attempt.
+ * Logic is ported one-to-one from {@code kotlin-exposed-dialect}'s {@code YdbRetryPolicy.kt}.
+ *
+ *
+ * {@link YdbDelayCalculator#fullJitterMillis} — {@code Aborted},
+ * {@code Undetermined}
+ * {@link YdbDelayCalculator#equalJitterMillis} on the fast tier —
+ * {@code Unavailable}, transport errors, {@code CLIENT_GRPC_ERROR}
+ * {@link YdbDelayCalculator#equalJitterMillis} on the slow tier —
+ * {@code Overloaded}, {@code CLIENT_RESOURCE_EXHAUSTED}
+ * zero delay — {@code BadSession}, {@code SessionBusy}, {@code SessionExpired}
+ *
+ *
+ * Not retried here (add handling if your workload needs it): {@code TIMEOUT},
+ * {@code CLIENT_DEADLINE_EXPIRED}, {@code PRECONDITION_FAILED}, and other vendor codes.
+ * Setting {@code idempotent = true} retries any code returned by this policy, not only
+ * {@link #isTransientVendorCode}. {@code SESSION_EXPIRED} is retried with zero backoff only when
+ * the call is marked idempotent and is not in the transient set.
+ */
+public final class YdbRetryPolicy {
+
+ /**
+ * Vendor codes retried even when the call is not marked idempotent. Matches
+ * {@code TRANSIENT_VENDOR_CODES} in kotlin-exposed.
+ */
+ private static final Set TRANSIENT_VENDOR_CODES = Set.of(
+ YdbVendorCode.ABORTED,
+ YdbVendorCode.UNAVAILABLE,
+ YdbVendorCode.OVERLOADED,
+ YdbVendorCode.CLIENT_RESOURCE_EXHAUSTED,
+ YdbVendorCode.BAD_SESSION,
+ YdbVendorCode.SESSION_BUSY);
+
+ private YdbRetryPolicy() {
+ }
+
+ /** Returns {@code true} if the vendor code is in the always-retried (transient) set. */
+ public static boolean isTransientVendorCode(int vendorCode) {
+ return TRANSIENT_VENDOR_CODES.contains(vendorCode);
+ }
+
+ /**
+ * Walks the cause chain of {@code error} looking for a {@link SQLException} with a non-zero
+ * vendor code. Mirrors kotlin-exposed's {@code extractVendorCode}.
+ *
+ * @return non-zero vendor code, or {@code 0} if no YDB-style code was found
+ */
+ public static int extractVendorCode(Throwable error) {
+ Throwable current = error;
+ while (current != null) {
+ if (current instanceof SQLException sqlException) {
+ int vendorCode = sqlException.getErrorCode();
+ if (vendorCode != 0) {
+ return vendorCode;
+ }
+ }
+ current = current.getCause();
+ }
+ return 0;
+ }
+
+ /**
+ * Computes the backoff delay for the next retry attempt, or returns an empty optional when
+ * the error must not be retried (either it is not a YDB error, the attempt budget is
+ * exhausted, or the code is non-retryable for the configured idempotency).
+ *
+ * Behaviour matches kotlin-exposed's {@code getNextRetryDelayMs} bit-for-bit.
+ */
+ public static OptionalLong getNextRetryDelayMs(
+ int vendorCode, int attempt, YdbRetryPolicyConfig config, boolean idempotent) {
+ if (attempt >= config.getMaxRetries()) {
+ return OptionalLong.empty();
+ }
+ if (vendorCode == 0) {
+ return OptionalLong.empty();
+ }
+ if (!idempotent && !isTransientVendorCode(vendorCode)) {
+ return OptionalLong.empty();
+ }
+
+ return switch (vendorCode) {
+ case YdbVendorCode.BAD_SESSION, YdbVendorCode.SESSION_BUSY, YdbVendorCode.SESSION_EXPIRED ->
+ OptionalLong.of(0L);
+ case YdbVendorCode.ABORTED, YdbVendorCode.UNDETERMINED ->
+ OptionalLong.of(YdbDelayCalculator.fullJitterMillis(
+ config.getFastBackoffBaseMs(),
+ config.getFastCapBackoffMs(),
+ config.getFastCeiling(),
+ attempt));
+ case YdbVendorCode.UNAVAILABLE, YdbVendorCode.TRANSPORT_UNAVAILABLE, YdbVendorCode.CLIENT_GRPC_ERROR ->
+ OptionalLong.of(YdbDelayCalculator.equalJitterMillis(
+ config.getFastBackoffBaseMs(),
+ config.getFastCapBackoffMs(),
+ config.getFastCeiling(),
+ attempt));
+ case YdbVendorCode.OVERLOADED, YdbVendorCode.CLIENT_RESOURCE_EXHAUSTED ->
+ OptionalLong.of(YdbDelayCalculator.equalJitterMillis(
+ config.getSlowBackoffBaseMs(),
+ config.getSlowCapBackoffMs(),
+ config.getSlowCeiling(),
+ attempt));
+ default -> OptionalLong.empty();
+ };
+ }
+}
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..ac9a3a50
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbRetryPolicyConfig.java
@@ -0,0 +1,149 @@
+package tech.ydb.retry;
+
+import org.springframework.lang.Nullable;
+
+/**
+ * Retry and backoff settings.
+ *
+ *
Two-tier backoff matches {@code kotlin-exposed-dialect}'s {@code YdbRetryConfig}:
+ *
+ * Fast tier: {@code ABORTED}, {@code UNDETERMINED}, {@code UNAVAILABLE},
+ * transport errors.
+ * Slow tier: {@code OVERLOADED}, {@code CLIENT_RESOURCE_EXHAUSTED}.
+ *
+ */
+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;
+
+ 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 slowCeiling;
+ private final int fastCeiling;
+
+ 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);
+ }
+
+ 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) {
+ throw new IllegalArgumentException("backoff values must be >= 0");
+ }
+ this.enabled = enabled;
+ this.maxRetries = maxRetries;
+ this.slowBackoffBaseMs = slowBackoffBaseMs;
+ this.fastBackoffBaseMs = fastBackoffBaseMs;
+ this.slowCapBackoffMs = slowCapBackoffMs;
+ this.fastCapBackoffMs = fastCapBackoffMs;
+ this.slowCeiling = ceilingFromCapBackoffMs(slowCapBackoffMs);
+ this.fastCeiling = ceilingFromCapBackoffMs(fastCapBackoffMs);
+ }
+
+ 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 getSlowCeiling() {
+ return slowCeiling;
+ }
+
+ public int getFastCeiling() {
+ return fastCeiling;
+ }
+
+ public YdbRetryPolicyConfig merge(@Nullable YdbTransactional transactionPolicy) {
+ if (transactionPolicy == null) {
+ return this;
+ }
+ return new YdbRetryPolicyConfig(
+ enabled && transactionPolicy.enabled(),
+ 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) {
+ if (candidate < -1) {
+ throw new IllegalArgumentException(String.format("%s is invalid", name));
+ }
+ return candidate == -1 ? fallback : candidate;
+ }
+
+ /**
+ * Ceiling on the exponent so that {@code baseMs * 2^ceiling} just reaches {@code capMs}.
+ * Ported one-to-one from kotlin-exposed: {@code ceil(ln(capMs + 1) / ln(2))}.
+ */
+ static int ceilingFromCapBackoffMs(int capBackoffMs) {
+ if (capBackoffMs <= 0) {
+ return 0;
+ }
+ double value = capBackoffMs + 1.0d;
+ return (int) Math.ceil(Math.log(value) / Math.log(2.0d));
+ }
+}
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..6e8033ce
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbRetryProperties.java
@@ -0,0 +1,72 @@
+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;
+
+ 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 YdbRetryPolicyConfig toConfig() {
+ return new YdbRetryPolicyConfig(
+ enabled,
+ maxRetries,
+ slowBackoffBaseMs,
+ fastBackoffBaseMs,
+ slowCapBackoffMs,
+ 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
new file mode 100644
index 00000000..e575a7ca
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionAutoConfiguration.java
@@ -0,0 +1,27 @@
+package tech.ydb.retry;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+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.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.transaction.interceptor.TransactionInterceptor;
+
+@AutoConfiguration
+@ConditionalOnClass(TransactionInterceptor.class)
+@EnableConfigurationProperties(YdbRetryProperties.class)
+@ConditionalOnProperty(prefix = "ydb.transaction.retry", name = "enabled", havingValue = "true", matchIfMissing = true)
+public class YdbTransactionAutoConfiguration {
+
+ private static final Logger log = LoggerFactory.getLogger(YdbTransactionAutoConfiguration.class);
+
+ @Bean
+ @ConditionalOnMissingBean
+ public static YdbTransactionInterceptorReplacer ydbTransactionInterceptorReplacer() {
+ log.debug("creating YdbTransactionInterceptorReplacer bean");
+ return new YdbTransactionInterceptorReplacer();
+ }
+}
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..079adc92
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptor.java
@@ -0,0 +1,173 @@
+package tech.ydb.retry;
+
+import java.lang.reflect.AnnotatedElement;
+import java.lang.reflect.Method;
+import java.util.OptionalLong;
+import org.aopalliance.intercept.MethodInvocation;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.aop.ProxyMethodInvocation;
+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;
+
+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)) {
+ if (log.isDebugEnabled()) {
+ log.debug("YDB retry is disabled for method "
+ + invocation.getMethod().toGenericString()
+ + " because it participates in an existing transaction");
+ }
+ return this.invokeWithinTransaction(invocation.getMethod(), targetClass, createCallback(invocation));
+ }
+
+ YdbTransactional ydbTransactional = resolveYdbTransactionAnnotation(invocation.getMethod(), targetClass);
+ YdbRetryPolicyConfig effectiveConfig = this.retryConfig.merge(ydbTransactional);
+ boolean isIdempotent = ydbTransactional != null && ydbTransactional.idempotent();
+
+ if (!effectiveConfig.isEnabled()) {
+ if (log.isDebugEnabled()) {
+ log.debug("YDB retry is disabled for method "
+ + invocation.getMethod().toGenericString());
+ }
+ return this.invokeWithinTransaction(invocation.getMethod(), targetClass, createCallback(invocation));
+ }
+
+ return invokeWithinTransactionWithRetryContext(invocation, targetClass, effectiveConfig, isIdempotent);
+ }
+
+ @Nullable
+ private Object invokeWithinTransactionWithRetryContext(
+ final MethodInvocation invocation,
+ @Nullable Class> targetClass,
+ YdbRetryPolicyConfig effectiveConfig,
+ boolean isIdempotent)
+ throws Throwable {
+ for (int attempt = 0; ; attempt++) {
+ try {
+ MethodInvocation cloneInvocation = cloneInvocation(invocation);
+ return this.invokeWithinTransaction(
+ invocation.getMethod(), targetClass, createCallback(cloneInvocation));
+ } catch (Throwable ex) {
+ if (ex instanceof Error) {
+ throw ex;
+ }
+ int vendorCode = YdbRetryPolicy.extractVendorCode(ex);
+ OptionalLong delay =
+ YdbRetryPolicy.getNextRetryDelayMs(vendorCode, attempt, effectiveConfig, isIdempotent);
+ if (delay.isEmpty()) {
+ throw ex;
+ }
+ sleep(delay.getAsLong(), ex);
+ }
+ }
+ }
+
+ 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 annotation = findYdbTransactional(specificMethod);
+ if (annotation != null) {
+ return annotation;
+ }
+
+ annotation = findYdbTransactional(targetClass);
+ if (annotation != null) {
+ return annotation;
+ }
+
+ 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;
+ }
+
+ 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();
+ }
+ };
+ }
+
+ 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
new file mode 100644
index 00000000..7f175300
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorFactory.java
@@ -0,0 +1,85 @@
+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() {
+ requireRetryProperties();
+ requireTransactionAttributeSource();
+
+ 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;
+ }
+
+ 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) {
+ 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..be566443
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactionInterceptorReplacer.java
@@ -0,0 +1,93 @@
+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)) {
+ if (log.isDebugEnabled()) {
+ log.debug("BeanDefinition '" + TRANSACTION_INTERCEPTOR_BEAN_NAME + "' not found");
+ }
+ return;
+ }
+
+ BeanDefinition existingBd = registry.getBeanDefinition(TRANSACTION_INTERCEPTOR_BEAN_NAME);
+
+ if (YdbTransactionInterceptorFactory.class.getName().equals(existingBd.getBeanClassName())) {
+ if (log.isDebugEnabled()) {
+ log.debug("BeanDefinition '" + TRANSACTION_INTERCEPTOR_BEAN_NAME
+ + "' is already YdbTransactionInterceptorFactory");
+ }
+ return;
+ }
+
+ AbstractBeanDefinition newBd = buildYdbInterceptorBeanDefinition(existingBd);
+
+ registry.removeBeanDefinition(TRANSACTION_INTERCEPTOR_BEAN_NAME);
+ registry.registerBeanDefinition(TRANSACTION_INTERCEPTOR_BEAN_NAME, newBd);
+
+ if (log.isDebugEnabled()) {
+ log.debug("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..e53c4690
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbTransactional.java
@@ -0,0 +1,109 @@
+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;
+
+/**
+ * Extends the standard {@code @Transactional} annotation
+ * with YDB-specific retry settings for re-executing a transactional method
+ * when a retryable YDB error occurs.
+ * Can be used with {@code @Transactional} in the same application.
+ */
+
+@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 extends Throwable>[] rollbackFor() default {};
+
+ @AliasFor(annotation = Transactional.class, attribute = "rollbackForClassName")
+ String[] rollbackForClassName() default {};
+
+ @AliasFor(annotation = Transactional.class, attribute = "noRollbackFor")
+ Class extends Throwable>[] noRollbackFor() default {};
+
+ @AliasFor(annotation = Transactional.class, attribute = "noRollbackForClassName")
+ String[] noRollbackForClassName() default {};
+
+ /**
+ * Enables or disables YDB retry for the annotated scope.
+ * This flag affects retry behavior only and does not disable transactional execution.
+ * Retry can be disabled for the annotated scope, but it cannot be enabled here if it is disabled in the global configuration.
+ */
+ boolean enabled() default true;
+
+ /**
+ * Specifies the maximum number of retry attempts after the initial failed execution.
+ * The annotated method may be executed up to {@code maxRetries + 1} times in total.
+ * Use {@code -1} to inherit the value from the global retry configuration.
+ * A value of {@code 0} is not supported; use {@code enabled() = false} to disable retry.
+ */
+ int maxRetries() default -1;
+
+ /**
+ * Overrides the base delay in milliseconds for the slow backoff strategy.
+ * Use {@code -1} to inherit the value from the global retry configuration.
+ */
+ int slowBackoffBaseMs() default -1;
+
+ /**
+ * Overrides the base delay in milliseconds for the fast backoff strategy.
+ * Use {@code -1} to inherit the value from the global retry configuration.
+ */
+ int fastBackoffBaseMs() default -1;
+
+ /**
+ * Overrides the maximum delay in milliseconds for the slow backoff strategy.
+ * Use {@code -1} to inherit the value from the global retry configuration.
+ */
+ int slowCapBackoffMs() default -1;
+
+ /**
+ * Overrides the maximum delay in milliseconds for the fast backoff strategy.
+ * Use {@code -1} to inherit the value from the global retry configuration.
+ */
+ int fastCapBackoffMs() default -1;
+
+ /**
+ * Marks the transactional method as idempotent for YDB retries.
+ * Some YDB errors are retryable only for idempotent operations.
+ */
+ boolean idempotent() default false;
+}
diff --git a/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbVendorCode.java b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbVendorCode.java
new file mode 100644
index 00000000..3cd7f22d
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/main/java/tech/ydb/retry/YdbVendorCode.java
@@ -0,0 +1,43 @@
+package tech.ydb.retry;
+
+/**
+ * YDB status codes as JDBC {@link java.sql.SQLException#getErrorCode() vendor codes}.
+ *
+ * Values match
+ * {@code tech.ydb.core.StatusCode}
+ * and
+ * {@code tech.ydb.core.Constants} .
+ * Kept as raw {@code int} constants so this module does not need {@code ydb-sdk-core} on the
+ * runtime classpath.
+ */
+public final class YdbVendorCode {
+
+ public static final int SERVER_STATUSES_FIRST = 400_000;
+ public static final int TRANSPORT_STATUSES_FIRST = 401_000;
+ public static final int INTERNAL_CLIENT_FIRST = 402_000;
+
+ public static final int ABORTED = SERVER_STATUSES_FIRST + 40;
+ public static final int UNAVAILABLE = SERVER_STATUSES_FIRST + 50;
+ public static final int OVERLOADED = SERVER_STATUSES_FIRST + 60;
+ public static final int TIMEOUT = SERVER_STATUSES_FIRST + 90;
+ public static final int BAD_SESSION = SERVER_STATUSES_FIRST + 100;
+ public static final int PRECONDITION_FAILED = SERVER_STATUSES_FIRST + 120;
+ public static final int NOT_FOUND = SERVER_STATUSES_FIRST + 140;
+ public static final int SESSION_EXPIRED = SERVER_STATUSES_FIRST + 150;
+ public static final int UNDETERMINED = SERVER_STATUSES_FIRST + 170;
+ public static final int SESSION_BUSY = SERVER_STATUSES_FIRST + 190;
+
+ public static final int TRANSPORT_UNAVAILABLE = TRANSPORT_STATUSES_FIRST + 10;
+ public static final int CLIENT_RESOURCE_EXHAUSTED = TRANSPORT_STATUSES_FIRST + 20;
+ public static final int CLIENT_DEADLINE_EXCEEDED = TRANSPORT_STATUSES_FIRST + 30;
+ public static final int CLIENT_INTERNAL_ERROR = TRANSPORT_STATUSES_FIRST + 50;
+ public static final int CLIENT_CANCELLED = TRANSPORT_STATUSES_FIRST + 60;
+
+ public static final int CLIENT_DISCOVERY_FAILED = INTERNAL_CLIENT_FIRST + 10;
+ public static final int CLIENT_LIMITS_REACHED = INTERNAL_CLIENT_FIRST + 20;
+ public static final int CLIENT_DEADLINE_EXPIRED = INTERNAL_CLIENT_FIRST + 30;
+ public static final int CLIENT_GRPC_ERROR = INTERNAL_CLIENT_FIRST + 40;
+
+ private YdbVendorCode() {
+ }
+}
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..a8b68ba7
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/InterceptorTestSupport.java
@@ -0,0 +1,226 @@
+package tech.ydb.retry;
+
+import java.lang.reflect.Method;
+import java.sql.SQLException;
+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.StatusCode;
+
+abstract class InterceptorTestSupport {
+
+ @AfterEach
+ void cleanupTransactionContext() {
+ TransactionSynchronizationManager.clear();
+ }
+
+ 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,
+ BackoffSleeper sleeper) {
+ TestableInterceptor interceptor =
+ new TestableInterceptor(
+ new YdbRetryPolicyConfig(enabled, maxRetries, slowBase, fastBase, slowCap, fastCap),
+ sleeper);
+ interceptor.setTransactionAttributeSource(new AnnotationTransactionAttributeSource());
+ return interceptor;
+ }
+
+ static MethodInvocation invocationFor(String methodName) {
+ 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]);
+ 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 {
+ try {
+ invocation.proceedWithInvocation();
+ } catch (Throwable ignored) {
+
+ }
+ 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 = 0)
+ public String ydbZeroMaxRetries() {
+ return "ok";
+ }
+
+ @YdbTransactional(maxRetries = 5, idempotent = true)
+ public String ydbIdempotentRetry() {
+ return "ok";
+ }
+
+ @YdbTransactional(maxRetries = 3)
+ public String ydbNonIdempotentRetry() {
+ return "ok";
+ }
+ }
+
+ /**
+ * Mimics what {@code tech.ydb.jdbc.exception.ExceptionFactory} produces at runtime: a
+ * {@link RuntimeException} that wraps a {@link SQLException} whose {@code errorCode} is the
+ * {@link StatusCode#getCode() YDB status code}. This is exactly the shape that the JDBC driver
+ * propagates and what Spring-Data wraps into a {@code DataAccessException}.
+ */
+ static final class ConfigurableStatusException extends RuntimeException {
+ private final StatusCode statusCode;
+
+ ConfigurableStatusException(StatusCode statusCode) {
+ super("test exception with status " + statusCode, new SQLException(
+ "test exception with status " + statusCode, null, statusCode.getCode()));
+ this.statusCode = statusCode;
+ }
+
+ StatusCode statusCode() {
+ return statusCode;
+ }
+ }
+}
diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/NestedYdbTransactionalRetryTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/NestedYdbTransactionalRetryTest.java
new file mode 100644
index 00000000..d51fedf1
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/NestedYdbTransactionalRetryTest.java
@@ -0,0 +1,229 @@
+package tech.ydb.retry;
+
+import java.util.concurrent.atomic.AtomicInteger;
+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.transaction.TransactionDefinition;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+import org.springframework.transaction.support.AbstractPlatformTransactionManager;
+import org.springframework.transaction.support.DefaultTransactionStatus;
+
+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;
+
+/**
+ * Locks in the contract that when one {@code @YdbTransactional} method invokes another
+ * {@code @YdbTransactional} method through a Spring proxy with {@link
+ * org.springframework.transaction.annotation.Propagation#REQUIRED REQUIRED} propagation, only the
+ * outer (root) transaction is retried as a whole and the inner method does not
+ * start its own retry loop.
+ *
+ * This is the "если пропагет транзакция, то не ретраим вложенную" invariant from the PR
+ * description. Without this property, an outer rollback would still spend retries on an inner
+ * call, which is both pointless (the outer tx is already doomed) and would break OCC semantics in
+ * YDB.
+ */
+class NestedYdbTransactionalRetryTest {
+
+ @Test
+ void shouldRetryOnlyOuterMethodWhenInnerJoinsExistingTransaction() {
+ try (AnnotationConfigApplicationContext context =
+ new AnnotationConfigApplicationContext(NestedConfig.class)) {
+ NestedService service = context.getBean(NestedService.class);
+ FlakyTransactionManager txManager = context.getBean(FlakyTransactionManager.class);
+
+ service.outer();
+
+ assertEquals(3, service.outerCount(), "outer must be re-invoked from scratch on retry");
+ assertEquals(3, service.innerCount(),
+ "inner is called once per outer attempt and must NOT add its own retry attempts");
+ assertEquals(3, txManager.beginCount(),
+ "only the outer attempts begin physical transactions (REQUIRED joins)");
+ assertEquals(2, txManager.rollbackCount());
+ assertEquals(1, txManager.commitCount());
+ }
+ }
+
+ @Test
+ void shouldNotRetryInnerWhenOuterFails() {
+ try (AnnotationConfigApplicationContext context =
+ new AnnotationConfigApplicationContext(NestedConfig.class)) {
+ NestedService service = context.getBean(NestedService.class);
+ FlakyTransactionManager txManager = context.getBean(FlakyTransactionManager.class);
+
+ service.failingInnerCallCounter().set(Integer.MAX_VALUE);
+
+ assertThrows(ConfigurableInnerException.class, service::outerWithFailingInner);
+
+ assertEquals(1, service.outerCount(),
+ "outer must not be retried for non-YDB inner failures");
+ assertEquals(1, service.innerCount(),
+ "inner must be invoked exactly once");
+ assertEquals(1, txManager.beginCount());
+ assertEquals(1, txManager.rollbackCount());
+ assertEquals(0, txManager.commitCount());
+ }
+ }
+
+ @Configuration(proxyBeanMethods = false)
+ @EnableTransactionManagement
+ @Import(YdbTransactionAutoConfiguration.class)
+ static class NestedConfig {
+ @Bean
+ FlakyTransactionManager flakyTransactionManager() {
+ return new FlakyTransactionManager();
+ }
+
+ @Bean
+ NestedService nestedService() {
+ return new NestedService();
+ }
+ }
+
+ /** Spring service whose outer method calls the inner method through the proxy. */
+ static class NestedService {
+ private final AtomicInteger outerCount = new AtomicInteger();
+ private final AtomicInteger innerCount = new AtomicInteger();
+ private final AtomicInteger failingInnerCallCounter = new AtomicInteger();
+
+ private NestedService self;
+
+ @org.springframework.beans.factory.annotation.Autowired
+ void setSelf(NestedService self) {
+ this.self = self;
+ }
+
+ @YdbTransactional(maxRetries = 5)
+ public void outer() {
+ outerCount.incrementAndGet();
+ self.inner();
+ }
+
+ @YdbTransactional(maxRetries = 5)
+ public void inner() {
+ innerCount.incrementAndGet();
+ if (innerCount.get() == 1) {
+ throw new ConfigurableStatusException(ABORTED);
+ }
+ if (innerCount.get() == 2) {
+ throw new ConfigurableStatusException(BAD_SESSION);
+ }
+ }
+
+ @YdbTransactional(maxRetries = 5)
+ public void outerWithFailingInner() {
+ outerCount.incrementAndGet();
+ self.failingInner();
+ }
+
+ @YdbTransactional(maxRetries = 5)
+ public void failingInner() {
+ innerCount.incrementAndGet();
+ throw new ConfigurableInnerException();
+ }
+
+ AtomicInteger failingInnerCallCounter() {
+ return failingInnerCallCounter;
+ }
+
+ int outerCount() {
+ return outerCount.get();
+ }
+
+ int innerCount() {
+ return innerCount.get();
+ }
+ }
+
+ /**
+ * Transaction manager that counts physical begin/commit/rollback calls and supports
+ * propagation-REQUIRED "join existing transaction" semantics via a thread-local TxObject with a
+ * rollback-only flag.
+ */
+ static final class FlakyTransactionManager extends AbstractPlatformTransactionManager {
+ private final AtomicInteger beginCount = new AtomicInteger();
+ private final AtomicInteger commitCount = new AtomicInteger();
+ private final AtomicInteger rollbackCount = new AtomicInteger();
+ private final ThreadLocal current = new ThreadLocal<>();
+
+ FlakyTransactionManager() {
+ setNestedTransactionAllowed(true);
+ }
+
+ @Override
+ protected Object doGetTransaction() {
+ TxObject existing = current.get();
+ if (existing != null) {
+ return existing;
+ }
+ return new TxObject();
+ }
+
+ @Override
+ protected boolean isExistingTransaction(Object transaction) {
+ return ((TxObject) transaction).active;
+ }
+
+ @Override
+ protected void doBegin(Object transaction, TransactionDefinition definition) {
+ beginCount.incrementAndGet();
+ TxObject tx = (TxObject) transaction;
+ tx.active = true;
+ current.set(tx);
+ }
+
+ @Override
+ protected void doCommit(DefaultTransactionStatus status) {
+ commitCount.incrementAndGet();
+ }
+
+ @Override
+ protected void doRollback(DefaultTransactionStatus status) {
+ rollbackCount.incrementAndGet();
+ }
+
+ @Override
+ protected void doSetRollbackOnly(DefaultTransactionStatus status) {
+ // No-op: tests only assert begin/commit/rollback counts; we just need this hook to
+ // exist so Spring does not throw IllegalTransactionStateException for participating
+ // transactions in propagation REQUIRED.
+ }
+
+ @Override
+ protected void doCleanupAfterCompletion(Object transaction) {
+ ((TxObject) transaction).active = false;
+ current.remove();
+ }
+
+ int beginCount() {
+ return beginCount.get();
+ }
+
+ int commitCount() {
+ return commitCount.get();
+ }
+
+ int rollbackCount() {
+ return rollbackCount.get();
+ }
+
+ private static final class TxObject {
+ boolean active;
+ }
+ }
+
+ static final class ConfigurableStatusException extends RuntimeException {
+ ConfigurableStatusException(tech.ydb.core.StatusCode statusCode) {
+ super(new java.sql.SQLException(
+ "test", null, statusCode.getCode()));
+ }
+ }
+
+ static final class ConfigurableInnerException extends RuntimeException {
+ }
+}
diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/RetryStartsFreshTransactionTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/RetryStartsFreshTransactionTest.java
new file mode 100644
index 00000000..7d23fd9a
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/RetryStartsFreshTransactionTest.java
@@ -0,0 +1,187 @@
+package tech.ydb.retry;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.aop.ProxyMethodInvocation;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.TransactionDefinition;
+import org.springframework.transaction.TransactionStatus;
+import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
+import org.springframework.transaction.support.SimpleTransactionStatus;
+
+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;
+
+/**
+ * Locks in the core "retrier captures the whole transaction" contract: the YDB retry interceptor
+ * must start a brand-new Spring transaction on every retry attempt and roll back the previous one.
+ *
+ * Unlike the rest of the suite, this test drives the real {@link YdbTransactionInterceptor}
+ * against a counting {@link PlatformTransactionManager} so that we observe Spring's actual
+ * begin/commit/rollback cycle, not a stubbed-out {@code invokeWithinTransaction}.
+ */
+class RetryStartsFreshTransactionTest extends InterceptorTestSupport {
+
+ @Test
+ void shouldStartFreshTransactionForEveryRetryAttempt() throws Throwable {
+ CountingTransactionManager txManager = new CountingTransactionManager();
+ YdbTransactionInterceptor interceptor = newInterceptor(txManager, 3);
+
+ ProxyMethodInvocation invocation = invocationReturning(
+ "ydbCustomRetry",
+ new ConfigurableStatusException(ABORTED),
+ new ConfigurableStatusException(BAD_SESSION),
+ "ok");
+
+ Object result = interceptor.invoke(invocation);
+
+ assertEquals("ok", result);
+ assertEquals(3, txManager.beginCount(), "every retry must begin a new transaction");
+ assertEquals(2, txManager.rollbackCount(), "each failing attempt must roll back its own tx");
+ assertEquals(1, txManager.commitCount(), "only the final successful attempt must commit");
+ }
+
+ @Test
+ void shouldRollbackEveryAttemptAndPropagateWhenMaxRetriesExhausted() {
+ CountingTransactionManager txManager = new CountingTransactionManager();
+ YdbTransactionInterceptor interceptor = newInterceptor(txManager, 2);
+
+ ProxyMethodInvocation invocation = invocationReturning(
+ "ydbCustomRetry",
+ new ConfigurableStatusException(ABORTED),
+ new ConfigurableStatusException(ABORTED),
+ new ConfigurableStatusException(ABORTED));
+
+ assertThrows(ConfigurableStatusException.class, () -> interceptor.invoke(invocation));
+
+ assertEquals(3, txManager.beginCount(), "max-retries + 1 attempts must each begin a tx");
+ assertEquals(3, txManager.rollbackCount(), "all three attempts must roll back");
+ assertEquals(0, txManager.commitCount(), "nothing must be committed");
+ }
+
+ @Test
+ void shouldNotRetryNonYdbExceptionAndRollbackOnlyOnce() {
+ CountingTransactionManager txManager = new CountingTransactionManager();
+ YdbTransactionInterceptor interceptor = newInterceptor(txManager, 5);
+
+ ProxyMethodInvocation invocation =
+ invocationReturning("ydbCustomRetry", new IllegalStateException("non-ydb"));
+
+ assertThrows(IllegalStateException.class, () -> interceptor.invoke(invocation));
+
+ assertEquals(1, txManager.beginCount());
+ assertEquals(1, txManager.rollbackCount());
+ assertEquals(0, txManager.commitCount());
+ }
+
+ @Test
+ void shouldBeginAndCommitOnceForHappyPath() throws Throwable {
+ CountingTransactionManager txManager = new CountingTransactionManager();
+ YdbTransactionInterceptor interceptor = newInterceptor(txManager, 3);
+
+ ProxyMethodInvocation invocation = invocationReturning("ydbCustomRetry", "ok");
+
+ Object result = interceptor.invoke(invocation);
+
+ assertEquals("ok", result);
+ assertEquals(1, txManager.beginCount());
+ assertEquals(0, txManager.rollbackCount());
+ assertEquals(1, txManager.commitCount());
+ }
+
+ private static YdbTransactionInterceptor newInterceptor(
+ PlatformTransactionManager txManager, int maxRetries) {
+ YdbTransactionInterceptor interceptor = new YdbTransactionInterceptor(
+ new YdbRetryPolicyConfig(true, maxRetries, 0, 0, 0, 0), delay -> {
+ });
+ interceptor.setTransactionAttributeSource(new AnnotationTransactionAttributeSource());
+ interceptor.setTransactionManager(txManager);
+ return interceptor;
+ }
+
+ /**
+ * Builds a {@link ProxyMethodInvocation} whose {@code invocableClone()} hands out a fresh stub
+ * for every attempt, mimicking Spring's reflective method-invocation contract under retry.
+ */
+ private static ProxyMethodInvocation invocationReturning(String methodName, Object... outcomes) {
+ Method method = methodOf(methodName);
+ Object target = new YdbTransactionalTestService();
+
+ List clones = new ArrayList<>(outcomes.length);
+ for (Object outcome : outcomes) {
+ ProxyMethodInvocation clone = stubInvocation(method, target);
+ stubProceed(clone, outcome);
+ clones.add(clone);
+ }
+
+ ProxyMethodInvocation root = stubInvocation(method, target);
+ if (clones.size() == 1) {
+ Mockito.when(root.invocableClone()).thenReturn(clones.get(0));
+ } else {
+ ProxyMethodInvocation[] tail = clones.subList(1, clones.size())
+ .toArray(new ProxyMethodInvocation[0]);
+ Mockito.when(root.invocableClone()).thenReturn(clones.get(0), tail);
+ }
+ return root;
+ }
+
+ private static ProxyMethodInvocation stubInvocation(Method method, Object target) {
+ ProxyMethodInvocation invocation = Mockito.mock(ProxyMethodInvocation.class);
+ Mockito.when(invocation.getMethod()).thenReturn(method);
+ Mockito.when(invocation.getThis()).thenReturn(target);
+ Mockito.when(invocation.getArguments()).thenReturn(new Object[0]);
+ return invocation;
+ }
+
+ private static void stubProceed(ProxyMethodInvocation invocation, Object outcome) {
+ try {
+ if (outcome instanceof Throwable throwable) {
+ Mockito.when(invocation.proceed()).thenThrow(throwable);
+ } else {
+ Mockito.when(invocation.proceed()).thenReturn(outcome);
+ }
+ } catch (Throwable e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ static final class CountingTransactionManager 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(true);
+ }
+
+ @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();
+ }
+ }
+}
diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/SqlExceptionStatusExtractionTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/SqlExceptionStatusExtractionTest.java
new file mode 100644
index 00000000..e687cae7
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/SqlExceptionStatusExtractionTest.java
@@ -0,0 +1,77 @@
+package tech.ydb.retry;
+
+import java.sql.SQLException;
+import org.junit.jupiter.api.Test;
+import org.springframework.dao.DataIntegrityViolationException;
+
+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;
+import static tech.ydb.core.StatusCode.SCHEME_ERROR;
+
+/**
+ * Verifies the {@link YdbTransactionInterceptor} extracts the YDB {@code StatusCode} purely from
+ * {@link SQLException#getErrorCode()} traversed through the exception chain (Spring-Data /
+ * application wrapping), without depending on any {@code ydb-jdbc-driver} type at runtime.
+ */
+class SqlExceptionStatusExtractionTest extends InterceptorTestSupport {
+
+ @Test
+ void shouldRetryWhenSqlExceptionDirectlyCarriesRetryableStatus() throws Throwable {
+ TestableInterceptor interceptor = interceptorWithConfig(true, 3, 0, 0, 0, 0);
+ interceptor.enqueueOutcome(plainSqlException(BAD_SESSION.getCode()), "ok");
+
+ Object result = interceptor.invoke(invocationFor("regularTx"));
+
+ assertEquals("ok", result);
+ assertEquals(2, interceptor.allInvocations());
+ }
+
+ @Test
+ void shouldRetryWhenSqlExceptionIsBuriedInSpringDataAccessWrapper() throws Throwable {
+ TestableInterceptor interceptor = interceptorWithConfig(true, 3, 0, 0, 0, 0);
+ DataIntegrityViolationException wrapper = new DataIntegrityViolationException(
+ "wrapped", plainSqlException(ABORTED.getCode()));
+ interceptor.enqueueOutcome(wrapper, "ok");
+
+ Object result = interceptor.invoke(invocationFor("regularTx"));
+
+ assertEquals("ok", result);
+ assertEquals(2, interceptor.allInvocations());
+ }
+
+ @Test
+ void shouldNotRetryWhenSqlExceptionHasNonRetryableStatus() {
+ TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0);
+ interceptor.enqueueOutcome(plainSqlException(SCHEME_ERROR.getCode()));
+
+ SQLException thrown =
+ assertThrows(SQLException.class, () -> interceptor.invoke(invocationFor("regularTx")));
+
+ assertEquals(SCHEME_ERROR.getCode(), thrown.getErrorCode());
+ assertEquals(1, interceptor.allInvocations());
+ }
+
+ @Test
+ void shouldNotRetryWhenSqlExceptionHasZeroVendorCode() {
+ TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0);
+ interceptor.enqueueOutcome(new SQLException("non-ydb driver", null, 0));
+
+ assertThrows(SQLException.class, () -> interceptor.invoke(invocationFor("regularTx")));
+ assertEquals(1, interceptor.allInvocations());
+ }
+
+ @Test
+ void shouldNotRetryWhenSqlExceptionHasUnknownVendorCode() {
+ TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0);
+ interceptor.enqueueOutcome(new SQLException("some-other-driver", null, 12345));
+
+ assertThrows(SQLException.class, () -> interceptor.invoke(invocationFor("regularTx")));
+ assertEquals(1, interceptor.allInvocations());
+ }
+
+ private static SQLException plainSqlException(int vendorCode) {
+ return new SQLException("ydb-like failure", null, vendorCode);
+ }
+}
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..0029778c
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/TransactionPropagationRetryTest.java
@@ -0,0 +1,63 @@
+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);
+ 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);
+ 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);
+ 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);
+ 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..58b10dca
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/TransactionalDefaultRetryTest.java
@@ -0,0 +1,199 @@
+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);
+ 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);
+ 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);
+ interceptor.enqueueOutcome(new ConfigurableStatusException(UNAUTHORIZED));
+
+ ConfigurableStatusException exception =
+ assertThrows(
+ ConfigurableStatusException.class,
+ () -> interceptor.invoke(invocationFor("regularTx")));
+
+ assertEquals(UNAUTHORIZED, exception.statusCode());
+ assertEquals(0, interceptor.retries());
+ assertEquals(1, interceptor.allInvocations());
+ }
+
+ @Test
+ void shouldNotRetryNonYdbRuntimeException() {
+ TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0);
+ 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);
+ 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);
+ 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, 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, 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(ABORTED);
+ 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("ydbIdempotentRetry")));
+ assertEquals("sleep interrupted", thrown.getMessage());
+ assertEquals(1, thrown.getSuppressed().length);
+ assertSame(originalException, thrown.getSuppressed()[0]);
+ assertTrue(Thread.currentThread().isInterrupted());
+ } finally {
+ Thread.interrupted();
+ }
+ }
+
+ @Test
+ void shouldNotRetryClientInternalErrorForTransactionalMethod() {
+ 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")));
+
+ assertEquals(CLIENT_INTERNAL_ERROR, exception.statusCode());
+ assertEquals(1, interceptor.allInvocations());
+ }
+
+ @Test
+ void shouldNotRetryTimeoutForTransactionalMethodWhenDefaultConfigNotIdempotent() {
+ TestableInterceptor interceptor = interceptorWithConfig(true, 3, 0, 0, 0, 0);
+ interceptor.enqueueOutcome(new ConfigurableStatusException(TIMEOUT));
+
+ ConfigurableStatusException exception =
+ assertThrows(
+ ConfigurableStatusException.class,
+ () -> interceptor.invoke(invocationFor("regularTx")));
+
+ assertEquals(TIMEOUT, exception.statusCode());
+ assertEquals(1, interceptor.allInvocations());
+ }
+
+ @Test
+ void shouldNotRetryWhenDisabledInConfig() {
+ 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")));
+
+ assertEquals(BAD_SESSION, exception.statusCode());
+ 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..1b41d18f
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbDelayCalculatorTest.java
@@ -0,0 +1,62 @@
+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;
+
+class YdbDelayCalculatorTest {
+
+ @Test
+ void shouldCalculateBackoffFromFirstRetryWithoutZeroDelayFormula() {
+ YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 5, 50, 5, 5_000, 500);
+
+ assertEquals(50, YdbDelayCalculator.calculateBackoffMillis(50, 5_000, config.getSlowCeiling(), 0));
+ assertEquals(5, YdbDelayCalculator.calculateBackoffMillis(5, 500, config.getFastCeiling(), 0));
+ }
+
+ @Test
+ void shouldClampToCapBackoff() {
+ YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 5, 1, 1, 300, 300);
+
+ assertEquals(300,
+ YdbDelayCalculator.calculateBackoffMillis(1, 300, config.getSlowCeiling(), 9));
+ assertEquals(300,
+ YdbDelayCalculator.calculateBackoffMillis(1, 300, config.getFastCeiling(), 9));
+ }
+
+ @Test
+ void shouldUseInclusiveRangeForFullJitter() {
+ Set observed = new HashSet<>();
+
+ for (int i = 0; i < 256; i++) {
+ long delay = YdbDelayCalculator.fullJitterMillis(1, 1, 1, 0);
+ assertTrue(delay >= 0 && delay <= 1);
+ observed.add(delay);
+ }
+
+ assertTrue(observed.contains(0L));
+ assertTrue(observed.contains(1L));
+ }
+
+ @Test
+ void shouldUseEqualJitterRange() {
+ Set observed = new HashSet<>();
+
+ for (int i = 0; i < 256; i++) {
+ long delay = YdbDelayCalculator.equalJitterMillis(2, 2, 1, 0);
+ assertTrue(delay >= 1 && delay <= 2);
+ observed.add(delay);
+ }
+
+ assertTrue(observed.contains(1L));
+ assertTrue(observed.contains(2L));
+ }
+
+ @Test
+ void shouldKeepOddRemainderForFirstOverloadedRetry() {
+ assertEquals(1, YdbDelayCalculator.equalJitterMillis(1, 1, 1, 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
new file mode 100644
index 00000000..4c30a3a5
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbRetryPolicyConfigTest.java
@@ -0,0 +1,272 @@
+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 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 ceilingShouldBeComputedFromCapValues() {
+ YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 5, 100, 20, 2000, 300);
+
+ int expectedSlowCeiling = (int) Math.ceil(Math.log(2001) / Math.log(2));
+ int expectedFastCeiling = (int) Math.ceil(Math.log(301) / Math.log(2));
+
+ assertEquals(expectedSlowCeiling, config.getSlowCeiling());
+ assertEquals(expectedFastCeiling, config.getFastCeiling());
+ }
+
+ @Test
+ void ceilingForSmallCapShouldBeOne() {
+ YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 1, 0, 0, 1, 1);
+ assertEquals(1, config.getSlowCeiling());
+ assertEquals(1, config.getFastCeiling());
+ }
+
+ @Test
+ void ceilingForCapTwoShouldBeTwo() {
+ YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 1, 0, 0, 2, 2);
+ assertEquals(2, config.getSlowCeiling());
+ assertEquals(2, config.getFastCeiling());
+ }
+
+ @Test
+ void ceilingForZeroCapShouldBeZero() {
+ YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 1, 0, 0, 0, 0);
+ assertEquals(0, config.getSlowCeiling());
+ assertEquals(0, config.getFastCeiling());
+ }
+
+ @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..a4c9315f
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbRetryPolicyTest.java
@@ -0,0 +1,128 @@
+package tech.ydb.retry;
+
+import java.sql.SQLException;
+import java.util.List;
+import java.util.OptionalLong;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static tech.ydb.retry.YdbVendorCode.ABORTED;
+import static tech.ydb.retry.YdbVendorCode.BAD_SESSION;
+import static tech.ydb.retry.YdbVendorCode.CLIENT_GRPC_ERROR;
+import static tech.ydb.retry.YdbVendorCode.CLIENT_RESOURCE_EXHAUSTED;
+import static tech.ydb.retry.YdbVendorCode.NOT_FOUND;
+import static tech.ydb.retry.YdbVendorCode.OVERLOADED;
+import static tech.ydb.retry.YdbVendorCode.PRECONDITION_FAILED;
+import static tech.ydb.retry.YdbVendorCode.SESSION_BUSY;
+import static tech.ydb.retry.YdbVendorCode.SESSION_EXPIRED;
+import static tech.ydb.retry.YdbVendorCode.TIMEOUT;
+import static tech.ydb.retry.YdbVendorCode.TRANSPORT_UNAVAILABLE;
+import static tech.ydb.retry.YdbVendorCode.UNAVAILABLE;
+import static tech.ydb.retry.YdbVendorCode.UNDETERMINED;
+
+class YdbRetryPolicyTest {
+
+ private static final YdbRetryPolicyConfig CONFIG = new YdbRetryPolicyConfig();
+
+ @Test
+ void shouldClassifyTransientCodes() {
+ List transient0 =
+ List.of(ABORTED, UNAVAILABLE, OVERLOADED, CLIENT_RESOURCE_EXHAUSTED, BAD_SESSION,
+ SESSION_BUSY);
+
+ for (int code : transient0) {
+ assertTrue(YdbRetryPolicy.isTransientVendorCode(code),
+ "Vendor code " + code + " must be transient");
+ }
+ }
+
+ @Test
+ void shouldNotClassifyIdempotentOnlyCodesAsTransient() {
+ for (int code : List.of(UNDETERMINED, TRANSPORT_UNAVAILABLE, CLIENT_GRPC_ERROR,
+ SESSION_EXPIRED)) {
+ assertFalse(YdbRetryPolicy.isTransientVendorCode(code),
+ "Vendor code " + code + " must not be transient");
+ }
+ }
+
+ @Test
+ void shouldRetryTransientCodesRegardlessOfIdempotence() {
+ for (int code : List.of(BAD_SESSION, SESSION_BUSY, ABORTED, UNAVAILABLE, OVERLOADED,
+ CLIENT_RESOURCE_EXHAUSTED)) {
+ assertTrue(YdbRetryPolicy.getNextRetryDelayMs(code, 0, CONFIG, false).isPresent(),
+ "Should retry transient " + code + " even when not idempotent");
+ assertTrue(YdbRetryPolicy.getNextRetryDelayMs(code, 0, CONFIG, true).isPresent(),
+ "Should retry transient " + code + " when idempotent");
+ }
+ }
+
+ @Test
+ void shouldRetryIdempotentOnlyCodesOnlyWhenIdempotent() {
+ for (int code : List.of(UNDETERMINED, TRANSPORT_UNAVAILABLE, CLIENT_GRPC_ERROR,
+ SESSION_EXPIRED)) {
+ assertTrue(YdbRetryPolicy.getNextRetryDelayMs(code, 0, CONFIG, false).isEmpty(),
+ "Must not retry idempotent-only " + code + " when not idempotent");
+ assertTrue(YdbRetryPolicy.getNextRetryDelayMs(code, 0, CONFIG, true).isPresent(),
+ "Must retry idempotent-only " + code + " when idempotent");
+ }
+ }
+
+ @Test
+ void shouldNeverRetryHardErrors() {
+ for (int code : List.of(TIMEOUT, PRECONDITION_FAILED, NOT_FOUND, 0, 999_999)) {
+ assertTrue(YdbRetryPolicy.getNextRetryDelayMs(code, 0, CONFIG, false).isEmpty(),
+ "Must not retry hard error " + code + " when not idempotent");
+ assertTrue(YdbRetryPolicy.getNextRetryDelayMs(code, 0, CONFIG, true).isEmpty(),
+ "Must not retry hard error " + code + " when idempotent");
+ }
+ }
+
+ @Test
+ void shouldUseZeroDelayForSessionStatuses() {
+ assertEquals(0L,
+ YdbRetryPolicy.getNextRetryDelayMs(BAD_SESSION, 0, CONFIG, false).getAsLong());
+ assertEquals(0L,
+ YdbRetryPolicy.getNextRetryDelayMs(SESSION_BUSY, 0, CONFIG, false).getAsLong());
+ assertEquals(0L,
+ YdbRetryPolicy.getNextRetryDelayMs(SESSION_EXPIRED, 0, CONFIG, true).getAsLong());
+ }
+
+ @Test
+ void shouldReturnEmptyWhenAttemptBudgetExhausted() {
+ YdbRetryPolicyConfig config = new YdbRetryPolicyConfig(true, 2, 0, 0, 0, 0);
+ assertTrue(YdbRetryPolicy.getNextRetryDelayMs(ABORTED, 0, config, false).isPresent());
+ assertTrue(YdbRetryPolicy.getNextRetryDelayMs(ABORTED, 1, config, false).isPresent());
+ assertTrue(YdbRetryPolicy.getNextRetryDelayMs(ABORTED, 2, config, false).isEmpty());
+ }
+
+ @Test
+ void shouldExtractZeroForNonYdbThrowable() {
+ assertEquals(0, YdbRetryPolicy.extractVendorCode(new IllegalStateException("not ydb")));
+ }
+
+ @Test
+ void shouldExtractZeroForNullErrorCodeSqlException() {
+ assertEquals(0, YdbRetryPolicy.extractVendorCode(new SQLException("no code", null, 0)));
+ }
+
+ @Test
+ void shouldExtractVendorCodeFromDirectSqlException() {
+ assertEquals(BAD_SESSION,
+ YdbRetryPolicy.extractVendorCode(new SQLException("ydb", null, BAD_SESSION)));
+ }
+
+ @Test
+ void shouldExtractVendorCodeFromCauseChain() {
+ Throwable wrapped = new RuntimeException("outer",
+ new RuntimeException("middle", new SQLException("ydb", null, ABORTED)));
+ assertEquals(ABORTED, YdbRetryPolicy.extractVendorCode(wrapped));
+ }
+
+ @Test
+ void shouldReturnEmptyDelayForZeroVendorCode() {
+ OptionalLong delay = YdbRetryPolicy.getNextRetryDelayMs(0, 0, CONFIG, true);
+ assertTrue(delay.isEmpty());
+ }
+}
diff --git a/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionAutoConfigurationTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionAutoConfigurationTest.java
new file mode 100644
index 00000000..bcdbed1c
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionAutoConfigurationTest.java
@@ -0,0 +1,96 @@
+package tech.ydb.retry;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.TransactionDefinition;
+import org.springframework.transaction.TransactionStatus;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.interceptor.TransactionInterceptor;
+import org.springframework.transaction.support.SimpleTransactionStatus;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class YdbTransactionAutoConfigurationTest {
+
+ private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+ .withConfiguration(AutoConfigurations.of(YdbTransactionAutoConfiguration.class))
+ .withUserConfiguration(TestConfig.class);
+
+ @Test
+ void shouldNotRegisterRetryWrapperWhenRetryDisabled() {
+ contextRunner
+ .withPropertyValues("ydb.transaction.retry.enabled=false")
+ .run(context -> {
+ assertThat(context).hasSingleBean(TransactionInterceptor.class);
+ assertThat(context).doesNotHaveBean(YdbTransactionInterceptorReplacer.class);
+ assertThat(context.getBean("transactionInterceptor"))
+ .isInstanceOf(TransactionInterceptor.class)
+ .isNotInstanceOf(YdbTransactionInterceptor.class);
+ });
+ }
+
+ @Test
+ void shouldReplaceTransactionInterceptorWhenRetryEnabled() {
+ contextRunner
+ .withPropertyValues("ydb.transaction.retry.enabled=true")
+ .run(context -> {
+ assertThat(context).hasSingleBean(TransactionInterceptor.class);
+ assertThat(context).hasSingleBean(YdbTransactionInterceptorReplacer.class);
+ assertThat(context.getBean("transactionInterceptor"))
+ .isInstanceOf(YdbTransactionInterceptor.class);
+ });
+ }
+
+ @Test
+ void shouldReplaceTransactionInterceptorWhenRetryPropertyMissing() {
+ contextRunner.run(context -> {
+ assertThat(context).hasSingleBean(TransactionInterceptor.class);
+ assertThat(context).hasSingleBean(YdbTransactionInterceptorReplacer.class);
+ assertThat(context.getBean("transactionInterceptor"))
+ .isInstanceOf(YdbTransactionInterceptor.class);
+ });
+ }
+
+ @Configuration(proxyBeanMethods = false)
+ @EnableTransactionManagement
+ static class TestConfig {
+
+ @Bean
+ PlatformTransactionManager transactionManager() {
+ return new RecordingTransactionManager();
+ }
+
+ @Bean
+ TestService testService() {
+ return new TestService();
+ }
+ }
+
+ static class TestService {
+
+ @Transactional
+ public void work() {
+ }
+ }
+
+ static final class RecordingTransactionManager implements PlatformTransactionManager {
+
+ @Override
+ public TransactionStatus getTransaction(TransactionDefinition definition) {
+ return new SimpleTransactionStatus();
+ }
+
+ @Override
+ public void commit(TransactionStatus status) {
+ }
+
+ @Override
+ public void rollback(TransactionStatus status) {
+ }
+ }
+}
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..be28d530
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorFactoryTest.java
@@ -0,0 +1,121 @@
+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);
+ YdbTransactionInterceptorFactory factory = new YdbTransactionInterceptorFactory();
+ factory.setRetryProperties(properties);
+ factory.setTransactionAttributeSource(new AnnotationTransactionAttributeSource());
+
+ assertNotNull(factory.getObject());
+ }
+
+ @Test
+ void getObjectShouldThrowIllegalStateWhenRetryPropertiesIsNull() {
+ YdbTransactionInterceptorFactory factory = new YdbTransactionInterceptorFactory();
+ factory.setTransactionAttributeSource(new AnnotationTransactionAttributeSource());
+
+ 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
+ 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/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/YdbTransactionInterceptorReplacerTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorReplacerTest.java
new file mode 100644
index 00000000..1db1a70f
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionInterceptorReplacerTest.java
@@ -0,0 +1,184 @@
+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..4353cccb
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionManagerResolutionTest.java
@@ -0,0 +1,359 @@
+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..ac79d9ce
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/YdbTransactionalConfigOverrideTest.java
@@ -0,0 +1,390 @@
+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_INTERNAL_ERROR;
+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);
+ 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);
+ 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);
+ interceptor.enqueueOutcome(
+ new ConfigurableStatusException(SESSION_BUSY),
+ new ConfigurableStatusException(OVERLOADED),
+ new ConfigurableStatusException(OVERLOADED));
+
+ ConfigurableStatusException exception =
+ assertThrows(
+ ConfigurableStatusException.class,
+ () -> interceptor.invoke(invocationFor("ydbCustomRetry")));
+
+ assertEquals(OVERLOADED, exception.statusCode());
+ assertEquals(2, interceptor.retries());
+ assertEquals(3, interceptor.allInvocations());
+ }
+
+ @Test
+ void shouldUseAnnotatedMaxRetriesWhenLowerThanConfig() {
+ TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0);
+ interceptor.enqueueOutcome(
+ new ConfigurableStatusException(OVERLOADED),
+ new ConfigurableStatusException(BAD_SESSION),
+ new ConfigurableStatusException(OVERLOADED));
+
+ ConfigurableStatusException exception =
+ assertThrows(
+ ConfigurableStatusException.class,
+ () -> interceptor.invoke(invocationFor("ydbCustomRetry")));
+
+ assertEquals(OVERLOADED, exception.statusCode());
+ assertEquals(2, interceptor.retries());
+ assertEquals(3, interceptor.allInvocations());
+ }
+
+ @Test
+ void shouldUseAnnotatedMaxRetriesWhenHigherThanConfig() throws Throwable {
+ 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),
+ "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);
+ 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);
+ interceptor.enqueueOutcome(new ConfigurableStatusException(CLIENT_CANCELLED), "ok");
+
+ ConfigurableStatusException exception =
+ assertThrows(
+ ConfigurableStatusException.class,
+ () -> interceptor.invoke(invocationFor("ydbNonIdempotentRetry")));
+
+ assertEquals(CLIENT_CANCELLED, exception.statusCode());
+ assertEquals(1, interceptor.allInvocations());
+ }
+
+ @Test
+ void shouldNotRetryClientCancelledEvenWhenIdempotent() {
+ TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0);
+ interceptor.enqueueOutcome(new ConfigurableStatusException(CLIENT_CANCELLED), "ok");
+
+ ConfigurableStatusException exception =
+ assertThrows(
+ ConfigurableStatusException.class,
+ () -> interceptor.invoke(invocationFor("ydbIdempotentRetry")));
+
+ assertEquals(CLIENT_CANCELLED, exception.statusCode());
+ assertEquals(1, interceptor.allInvocations());
+ }
+
+ @Test
+ void shouldNotRetryClientInternalErrorEvenWhenIdempotent() {
+ TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0);
+ interceptor.enqueueOutcome(new ConfigurableStatusException(CLIENT_INTERNAL_ERROR), "ok");
+
+ ConfigurableStatusException exception =
+ assertThrows(
+ ConfigurableStatusException.class,
+ () -> interceptor.invoke(invocationFor("ydbIdempotentRetry")));
+
+ assertEquals(CLIENT_INTERNAL_ERROR, exception.statusCode());
+ assertEquals(1, interceptor.allInvocations());
+ }
+
+ @Test
+ void shouldUseInterfaceMethodYdbTransactionalOverrides() throws Throwable {
+ TestableInterceptor interceptor = interceptorWithConfig(true, 1, 0, 0, 0, 0);
+ interceptor.enqueueOutcome(new ConfigurableStatusException(TRANSPORT_UNAVAILABLE), "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);
+ interceptor.enqueueOutcome(new ConfigurableStatusException(TRANSPORT_UNAVAILABLE), "ok");
+
+ ConfigurableStatusException exception =
+ assertThrows(
+ ConfigurableStatusException.class,
+ () -> interceptor.invoke(invocationFor("ydbNonIdempotentRetry")));
+
+ assertEquals(TRANSPORT_UNAVAILABLE, exception.statusCode());
+ assertEquals(1, interceptor.allInvocations());
+ }
+
+ @Test
+ void shouldRetryTransportUnavailableWhenIdempotent() throws Throwable {
+ TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0);
+ 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);
+ 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);
+ 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);
+ interceptor.enqueueOutcome(new ConfigurableStatusException(TIMEOUT));
+
+ ConfigurableStatusException exception =
+ assertThrows(
+ ConfigurableStatusException.class,
+ () -> interceptor.invoke(invocationFor("ydbIdempotentRetry")));
+
+ assertEquals(TIMEOUT, exception.statusCode());
+ assertEquals(1, interceptor.allInvocations());
+ }
+
+ @Test
+ void shouldNotRetrySessionExpiredWhenNotIdempotent() {
+ TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0);
+ interceptor.enqueueOutcome(new ConfigurableStatusException(SESSION_EXPIRED));
+
+ ConfigurableStatusException exception =
+ assertThrows(
+ ConfigurableStatusException.class,
+ () -> interceptor.invoke(invocationFor("ydbNonIdempotentRetry")));
+
+ assertEquals(SESSION_EXPIRED, exception.statusCode());
+ assertEquals(1, interceptor.allInvocations());
+ }
+
+ @Test
+ void shouldRetryAlwaysRetryableCodesWhenIdempotent() throws Throwable {
+ TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0);
+ 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);
+ interceptor.enqueueOutcome(
+ new ConfigurableStatusException(ABORTED),
+ new ConfigurableStatusException(UNDETERMINED),
+ new ConfigurableStatusException(TRANSPORT_UNAVAILABLE),
+ "ok");
+
+ Object result = interceptor.invoke(invocationFor("ydbIdempotentRetry"));
+
+ assertEquals("ok", result);
+ assertEquals(4, interceptor.allInvocations());
+ }
+
+ @Test
+ void shouldRetrySessionExpiredWithZeroDelayWhenIdempotent() throws Throwable {
+ TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0);
+ interceptor.enqueueOutcome(new ConfigurableStatusException(SESSION_EXPIRED), "ok");
+
+ Object result = interceptor.invoke(invocationFor("ydbIdempotentRetry"));
+
+ assertEquals("ok", result);
+ assertEquals(2, interceptor.allInvocations());
+ }
+
+ @Test
+ void shouldStopAtIdempotentOnlyCodeWhenNotIdempotent() {
+ TestableInterceptor interceptor = interceptorWithConfig(true, 5, 0, 0, 0, 0);
+ interceptor.enqueueOutcome(
+ new ConfigurableStatusException(BAD_SESSION), new ConfigurableStatusException(TIMEOUT));
+
+ ConfigurableStatusException exception =
+ assertThrows(
+ ConfigurableStatusException.class,
+ () -> interceptor.invoke(invocationFor("ydbNonIdempotentRetry")));
+
+ assertEquals(TIMEOUT, exception.statusCode());
+ assertEquals(2, interceptor.allInvocations());
+ }
+
+ @Test
+ void shouldNotReachDelayCalculatorForTimeoutWhenIdempotent() {
+ List delays = new ArrayList<>();
+ TestableInterceptor interceptor =
+ interceptorWithSleeper(true, 5, 100, 50, 1000, 500, delays::add);
+ interceptor.enqueueOutcome(new ConfigurableStatusException(TIMEOUT));
+
+ assertThrows(
+ ConfigurableStatusException.class,
+ () -> interceptor.invoke(invocationFor("ydbIdempotentRetry")));
+
+ assertEquals(1, interceptor.allInvocations());
+ assertEquals(0, delays.size());
+ }
+
+ @Test
+ void shouldUseZeroDelayForSessionExpiredWhenIdempotent() throws Throwable {
+ List delays = new ArrayList<>();
+ TestableInterceptor interceptor =
+ interceptorWithSleeper(true, 5, 100, 50, 1000, 500, delays::add);
+ interceptor.enqueueOutcome(new ConfigurableStatusException(SESSION_EXPIRED), "ok");
+
+ Object result = interceptor.invoke(invocationFor("ydbIdempotentRetry"));
+
+ assertEquals("ok", result);
+ assertEquals(2, interceptor.allInvocations());
+ assertEquals(List.of(0L), delays);
+ }
+
+ @Test
+ void shouldUseFastBackoffForUndeterminedWhenIdempotent() throws Throwable {
+ List delays = new ArrayList<>();
+ TestableInterceptor interceptor =
+ interceptorWithSleeper(true, 5, 100, 50, 1000, 500, delays::add);
+ interceptor.enqueueOutcome(new ConfigurableStatusException(UNDETERMINED), "ok");
+
+ interceptor.invoke(invocationFor("ydbIdempotentRetry"));
+
+ assertEquals(1, delays.size());
+ assertTrue(delays.get(0) >= 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);
+ interceptor.enqueueOutcome(new ConfigurableStatusException(BAD_SESSION), "ok");
+
+ ConfigurableStatusException exception =
+ assertThrows(
+ ConfigurableStatusException.class,
+ () -> interceptor.invoke(invocationFor("ydbRetryDisabled")));
+
+ assertEquals(BAD_SESSION, exception.statusCode());
+ assertEquals(1, interceptor.allInvocations());
+ }
+
+ @Test
+ void shouldNotRetryWhenGlobalConfigDisablesRetryEvenIfMethodEnablesIt() {
+ 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")));
+
+ assertEquals(BAD_SESSION, exception.statusCode());
+ 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/CombinedErrorIntegrationTest.java b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/CombinedErrorIntegrationTest.java
new file mode 100644
index 00000000..dde2b544
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/CombinedErrorIntegrationTest.java
@@ -0,0 +1,89 @@
+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..a93a1e5f
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/CommitTransactionRetryTest.java
@@ -0,0 +1,62 @@
+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..33c6de77
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ConcurrentRunner.java
@@ -0,0 +1,91 @@
+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 {
+ 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();
+ }
+ }
+ }
+
+ boolean isShutdown() {
+ return executor.isShutdown();
+ }
+
+ 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..23e1590c
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/ConcurrentWriteIntegrationTest.java
@@ -0,0 +1,87 @@
+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..c8bc0b27
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DeterministicErrorChannel.java
@@ -0,0 +1,169 @@
+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..31394b3c
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DeterministicErrorChannelTest.java
@@ -0,0 +1,25 @@
+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;
+
+@YdbIntegrationTest
+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..0b39b591
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/DisabledRetryIntegrationTest.java
@@ -0,0 +1,54 @@
+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..64eb03a8
--- /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..0d21a015
--- /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 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;
+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;
+
+@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..eaab1b7d
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/IdempotentRetryIntegrationTest.java
@@ -0,0 +1,144 @@
+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"})
+ void shouldNotRetryTimeoutWhenIdempotentExecuteQuery(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 = "Idempotent executeQuery retried with zero delay")
+ @EnumSource(
+ value = StatusCode.class,
+ names = {"SESSION_EXPIRED"})
+ void shouldRetrySessionExpiredWhenIdempotentExecuteQuery(StatusCode code) {
+ DeterministicErrorChannel.configure().onError("executeQuery", 1, code);
+
+ userService.saveIdempotent(createUser(10L, "user10", "first10", "last10"));
+
+ assertEquals(2, DeterministicErrorChannel.getCallCount("executeQuery"));
+ assertNotNull(userService.findById(10L));
+ }
+
+ @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"})
+ void shouldNotRetryTimeoutWhenIdempotentCommit(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 retried with zero delay")
+ @EnumSource(
+ value = StatusCode.class,
+ names = {"SESSION_EXPIRED"})
+ void shouldRetrySessionExpiredWhenIdempotentCommit(StatusCode code) {
+ DeterministicErrorChannel.configure().onError("commitTransaction", 1, code);
+
+ userService.saveIdempotent(createUser(11L, "user11", "first11", "last11"));
+
+ assertEquals(2, DeterministicErrorChannel.getCallCount("commitTransaction"));
+ assertNotNull(userService.findById(11L));
+ }
+
+ @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..d52b4047
--- /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;
+
+@YdbIntegrationTest
+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..4e1510be
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/MaxRetriesExhaustedTest.java
@@ -0,0 +1,60 @@
+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..a5d83401
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/NonRetryableCommitIntegrationTest.java
@@ -0,0 +1,62 @@
+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..fd5c9bd3
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/YdbDockerTest.java
@@ -0,0 +1,41 @@
+package tech.ydb.retry.integration;
+
+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();
+
+ @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/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/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..3c50caad
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/java/tech/ydb/retry/integration/app/UserApplication.java
@@ -0,0 +1,16 @@
+package tech.ydb.retry.integration.app;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Import;
+import org.springframework.data.jdbc.repository.config.EnableJdbcRepositories;
+import tech.ydb.data.repository.config.AbstractYdbJdbcConfiguration;
+
+@EnableJdbcRepositories
+@SpringBootApplication
+@Import(AbstractYdbJdbcConfiguration.class)
+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..b493645a
--- /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 = true)
+ public void saveIdempotent(User user) {
+ userRepository.save(user);
+ }
+
+ @YdbTransactional(maxRetries = 50, idempotent = true)
+ 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..378bc968
--- /dev/null
+++ b/spring-ydb/spring-ydb-retry/src/test/resources/application-enabled.properties
@@ -0,0 +1,12 @@
+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
+
+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