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}