001/* 002 * Copyright 2015-2022 Transmogrify LLC, 2022-2026 Revetware LLC. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package com.pyranid; 018 019import org.jspecify.annotations.NonNull; 020 021import javax.annotation.concurrent.ThreadSafe; 022import java.time.Duration; 023 024import static java.lang.String.format; 025import static java.util.Objects.requireNonNull; 026 027/** 028 * Defines when and how {@link Database#transactionWithRetry(RetryPolicy, TransactionalOperation)} retries a transaction. 029 * <p> 030 * A retry policy requires all retry-critical decisions to be explicit: total attempts, retry condition, and backoff. 031 * 032 * @author <a href="https://www.revetkn.com">Mark Allen</a> 033 * @since 4.4.0 034 */ 035@ThreadSafe 036public final class RetryPolicy { 037 @NonNull 038 private final Integer maxAttempts; 039 @NonNull 040 private final Condition condition; 041 @NonNull 042 private final Backoff backoff; 043 044 private RetryPolicy(@NonNull Integer maxAttempts, 045 @NonNull Condition condition, 046 @NonNull Backoff backoff) { 047 this.maxAttempts = maxAttempts; 048 this.condition = condition; 049 this.backoff = backoff; 050 } 051 052 /** 053 * Creates a retry policy. 054 * <p> 055 * {@code maxAttempts} is the total number of attempts, including the initial attempt. For example, {@code 3} means one 056 * initial attempt plus up to two retries. 057 * 058 * @param maxAttempts total attempts, including the initial attempt 059 * @param backoff determines how long to wait before the next retry 060 * @param condition determines whether a failed attempt is retryable 061 * @return a retry policy 062 */ 063 @NonNull 064 public static RetryPolicy ofMaxAttempts(@NonNull Integer maxAttempts, 065 @NonNull Backoff backoff, 066 @NonNull Condition condition) { 067 requireNonNull(maxAttempts); 068 requireNonNull(backoff); 069 requireNonNull(condition); 070 071 if (maxAttempts < 1) 072 throw new IllegalArgumentException("maxAttempts must be at least 1"); 073 074 return new RetryPolicy(maxAttempts, condition, backoff); 075 } 076 077 /** 078 * Gets the total number of attempts, including the initial attempt. 079 * 080 * @return total attempts 081 */ 082 @NonNull 083 public Integer getMaxAttempts() { 084 return this.maxAttempts; 085 } 086 087 /** 088 * Gets the retry condition. 089 * 090 * @return retry condition 091 */ 092 @NonNull 093 public Condition getCondition() { 094 return this.condition; 095 } 096 097 /** 098 * Gets the retry backoff. 099 * 100 * @return retry backoff 101 */ 102 @NonNull 103 public Backoff getBackoff() { 104 return this.backoff; 105 } 106 107 @Override 108 @NonNull 109 public String toString() { 110 return format("%s{maxAttempts=%s, condition=%s, backoff=%s}", 111 getClass().getSimpleName(), getMaxAttempts(), getCondition(), getBackoff()); 112 } 113 114 /** 115 * Determines whether a failed transaction attempt should be retried. 116 * <p> 117 * Implementations should be thread-safe if the {@link RetryPolicy} is shared across threads. 118 * 119 * @author <a href="https://www.revetkn.com">Mark Allen</a> 120 * @since 4.4.0 121 */ 122 @FunctionalInterface 123 public interface Condition { 124 /** 125 * Determines whether a failed transaction attempt should be retried. 126 * 127 * @param failure the database failure that caused the transaction attempt to fail 128 * @return {@code true} to retry, {@code false} to stop retrying 129 */ 130 @NonNull 131 Boolean shouldRetry(@NonNull DatabaseException failure); 132 133 /** 134 * Creates a condition that retries serialization failures and deadlocks. 135 * 136 * @return a serialization-failure-or-deadlock retry condition 137 */ 138 @NonNull 139 static Condition serializationFailureOrDeadlock() { 140 return failure -> { 141 requireNonNull(failure); 142 return failure.isSerializationFailure() || failure.isDeadlock(); 143 }; 144 } 145 146 /** 147 * Creates a condition that retries serialization failures. 148 * 149 * @return a serialization-failure retry condition 150 */ 151 @NonNull 152 static Condition serializationFailure() { 153 return failure -> { 154 requireNonNull(failure); 155 return failure.isSerializationFailure(); 156 }; 157 } 158 159 /** 160 * Creates a condition that retries deadlocks. 161 * 162 * @return a deadlock retry condition 163 */ 164 @NonNull 165 static Condition deadlock() { 166 return failure -> { 167 requireNonNull(failure); 168 return failure.isDeadlock(); 169 }; 170 } 171 172 /** 173 * Creates a condition that retries timeouts and cancellations recognized by Pyranid. 174 * <p> 175 * Timeouts and cancellations are not always safe to retry: the database may have completed part or all of the work 176 * before the timeout surfaced to the caller. Use this condition only when the retried transaction body is safe to run 177 * more than once for the application workflow. 178 * 179 * @return a timeout retry condition 180 */ 181 @NonNull 182 static Condition timeout() { 183 return failure -> { 184 requireNonNull(failure); 185 return failure.isTimeout(); 186 }; 187 } 188 } 189 190 /** 191 * Determines how long to wait before retrying after a failed transaction attempt. 192 * <p> 193 * Implementations should be thread-safe if the {@link RetryPolicy} is shared across threads. 194 * 195 * @author <a href="https://www.revetkn.com">Mark Allen</a> 196 * @since 4.4.0 197 */ 198 @FunctionalInterface 199 public interface Backoff { 200 /** 201 * Determines how long to wait after a failed attempt before the next retry. 202 * 203 * @param failedAttemptNumber one-based number of the attempt that just failed 204 * @param failure the database failure that caused the transaction attempt to fail 205 * @return delay before the next retry 206 */ 207 @NonNull 208 Duration delayAfterFailedAttempt(@NonNull Integer failedAttemptNumber, 209 @NonNull DatabaseException failure); 210 211 /** 212 * Creates a fixed backoff. 213 * 214 * @param delay delay before every retry 215 * @return a fixed backoff 216 */ 217 @NonNull 218 static Backoff fixed(@NonNull Duration delay) { 219 requireNonNull(delay); 220 validateDelay(delay, "delay"); 221 222 return (failedAttemptNumber, failure) -> { 223 requireNonNull(failedAttemptNumber); 224 requireNonNull(failure); 225 validateFailedAttemptNumber(failedAttemptNumber); 226 return delay; 227 }; 228 } 229 230 /** 231 * Creates an exponential backoff. 232 * <p> 233 * Failed attempt 1 returns {@code initialDelay}, failed attempt 2 returns {@code initialDelay * 2}, failed attempt 3 234 * returns {@code initialDelay * 4}, and so on until the delay reaches {@code maxDelay}. Arithmetic overflow saturates 235 * at {@code maxDelay}. 236 * 237 * @param initialDelay initial delay 238 * @param maxDelay maximum delay 239 * @return an exponential backoff 240 */ 241 @NonNull 242 static Backoff exponential(@NonNull Duration initialDelay, 243 @NonNull Duration maxDelay) { 244 requireNonNull(initialDelay); 245 requireNonNull(maxDelay); 246 validateDelay(initialDelay, "initialDelay"); 247 validateDelay(maxDelay, "maxDelay"); 248 249 if (maxDelay.compareTo(initialDelay) < 0) 250 throw new IllegalArgumentException("maxDelay must be greater than or equal to initialDelay"); 251 252 return (failedAttemptNumber, failure) -> { 253 requireNonNull(failedAttemptNumber); 254 requireNonNull(failure); 255 validateFailedAttemptNumber(failedAttemptNumber); 256 257 if (initialDelay.isZero()) 258 return Duration.ZERO; 259 260 Duration delay = initialDelay; 261 262 for (int attempt = 1; attempt < failedAttemptNumber && delay.compareTo(maxDelay) < 0; ++attempt) { 263 try { 264 delay = delay.multipliedBy(2); 265 } catch (ArithmeticException e) { 266 return maxDelay; 267 } 268 269 if (delay.compareTo(maxDelay) > 0) 270 return maxDelay; 271 } 272 273 return delay; 274 }; 275 } 276 277 private static void validateDelay(@NonNull Duration delay, 278 @NonNull String name) { 279 requireNonNull(delay); 280 requireNonNull(name); 281 282 if (delay.isNegative()) 283 throw new IllegalArgumentException(format("%s must not be negative", name)); 284 } 285 286 private static void validateFailedAttemptNumber(@NonNull Integer failedAttemptNumber) { 287 requireNonNull(failedAttemptNumber); 288 289 if (failedAttemptNumber < 1) 290 throw new IllegalArgumentException("failedAttemptNumber must be at least 1"); 291 } 292 } 293}