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;
020import org.jspecify.annotations.Nullable;
021
022import javax.annotation.concurrent.ThreadSafe;
023import javax.sql.DataSource;
024import java.sql.Connection;
025import java.sql.SQLException;
026import java.sql.SQLFeatureNotSupportedException;
027import java.sql.Savepoint;
028import java.time.Duration;
029import java.util.Collections;
030import java.util.List;
031import java.util.Optional;
032import java.util.concurrent.CopyOnWriteArrayList;
033import java.util.concurrent.atomic.AtomicBoolean;
034import java.util.concurrent.atomic.AtomicLong;
035import java.util.concurrent.locks.ReentrantLock;
036import java.util.function.Consumer;
037import java.util.function.Function;
038import java.util.logging.Level;
039import java.util.logging.Logger;
040
041import static java.lang.String.format;
042import static java.lang.System.nanoTime;
043import static java.util.Objects.requireNonNull;
044
045/**
046 * Represents a database transaction.
047 * <p>
048 * Note that commit and rollback operations are controlled internally by {@link Database}.
049 *
050 * @author <a href="https://www.revetkn.com">Mark Allen</a>
051 * @since 1.0.0
052 */
053@ThreadSafe
054public final class Transaction {
055        @NonNull
056        private static final AtomicLong ID_GENERATOR;
057        @NonNull
058        private static final Logger LOGGER;
059
060        static {
061                ID_GENERATOR = new AtomicLong(0);
062                LOGGER = Logger.getLogger(Transaction.class.getName());
063        }
064
065        @NonNull
066        private final Long id;
067        @NonNull
068        private final DataSource dataSource;
069        @NonNull
070        private final TransactionOptions transactionOptions;
071        @NonNull
072        private final TransactionIsolation transactionIsolation;
073        @NonNull
074        private final MetricsCollectorDispatcher metricsCollectorDispatcher;
075        @NonNull
076        private volatile DatabaseType databaseType;
077        @NonNull
078        private final Function<@NonNull Connection, @NonNull DatabaseType> databaseTypeResolver;
079        @NonNull
080        private final List<@NonNull Consumer<TransactionResult>> postTransactionOperations;
081        @NonNull
082        private final ReentrantLock connectionLock;
083        @NonNull
084        private final AtomicBoolean rollbackOnly;
085        @NonNull
086        private final AtomicBoolean completed;
087        @Nullable
088        private volatile Connection connection;
089        @Nullable
090        private volatile Boolean initialAutoCommit;
091        @Nullable
092        private volatile Boolean initialReadOnly;
093        @Nullable
094        private volatile Integer initialTransactionIsolationJdbcLevel;
095        @Nullable
096        private volatile Long connectionAcquiredAtNanos;
097        @NonNull
098        private final AtomicBoolean transactionIsolationWasChanged;
099        @NonNull
100        private final AtomicBoolean readOnlyWasChanged;
101
102        Transaction(@NonNull DataSource dataSource,
103                                                                @NonNull TransactionOptions transactionOptions,
104                                                                @NonNull MetricsCollectorDispatcher metricsCollectorDispatcher,
105                                                                @NonNull DatabaseType databaseType) {
106                this(dataSource, transactionOptions, metricsCollectorDispatcher, databaseType, connection -> databaseType);
107        }
108
109        Transaction(@NonNull DataSource dataSource,
110                                                                @NonNull TransactionOptions transactionOptions,
111                                                                @NonNull MetricsCollectorDispatcher metricsCollectorDispatcher,
112                                                                @NonNull DatabaseType databaseType,
113                                                                @NonNull Function<@NonNull Connection, @NonNull DatabaseType> databaseTypeResolver) {
114                requireNonNull(dataSource);
115                requireNonNull(transactionOptions);
116                requireNonNull(metricsCollectorDispatcher);
117                requireNonNull(databaseType);
118                requireNonNull(databaseTypeResolver);
119
120                this.id = generateId();
121                this.dataSource = dataSource;
122                this.transactionOptions = transactionOptions;
123                this.transactionIsolation = transactionOptions.getIsolation();
124                this.metricsCollectorDispatcher = metricsCollectorDispatcher;
125                this.databaseType = databaseType;
126                this.databaseTypeResolver = databaseTypeResolver;
127                this.connection = null;
128                this.rollbackOnly = new AtomicBoolean(false);
129                this.completed = new AtomicBoolean(false);
130                this.initialAutoCommit = null;
131                this.initialReadOnly = null;
132                this.connectionAcquiredAtNanos = null;
133                this.transactionIsolationWasChanged = new AtomicBoolean(false);
134                this.readOnlyWasChanged = new AtomicBoolean(false);
135                this.postTransactionOperations = new CopyOnWriteArrayList();
136                this.connectionLock = new ReentrantLock();
137        }
138
139        @Override
140        @NonNull
141        public String toString() {
142                return format("%s{id=%s, transactionIsolation=%s, hasConnection=%s, isRollbackOnly=%s}",
143                                getClass().getSimpleName(), id(), getTransactionIsolation(), hasConnection(), isRollbackOnly());
144        }
145
146        /**
147         * Creates a transaction savepoint that can be rolled back to via {@link #rollback(Savepoint)}.
148         * <p>
149         * For most application code, prefer {@link #withSavepoint(TransactionalOperation)} or
150         * {@link #withSavepoint(ReturningTransactionalOperation)} so rollback and release cleanup are handled automatically.
151         *
152         * @return a transaction savepoint
153         * @throws IllegalStateException if this transaction has already completed
154         */
155        @NonNull
156        public Savepoint createSavepoint() {
157                lockConnectionInterruptibly("create a savepoint");
158
159                try {
160                        assertNotCompleted("create a savepoint");
161                        Savepoint savepoint = getConnection().setSavepoint();
162                        getMetricsCollectorDispatcher().didCreateSavepoint(this, getDatabaseType());
163                        return savepoint;
164                } catch (SQLException e) {
165                        throw databaseException("Unable to create savepoint", e);
166                } finally {
167                        getConnectionLock().unlock();
168                }
169        }
170
171        /**
172         * Rolls back to the provided transaction savepoint.
173         *
174         * @param savepoint the savepoint to roll back to
175         * @throws IllegalStateException if this transaction has already completed
176         */
177        public void rollback(@NonNull Savepoint savepoint) {
178                requireNonNull(savepoint);
179                boolean connectionLockAcquired = false;
180
181                try {
182                        lockConnectionInterruptibly("roll back to a savepoint");
183                        connectionLockAcquired = true;
184                        assertNotCompleted("roll back to a savepoint");
185                        getConnection().rollback(savepoint);
186                        getMetricsCollectorDispatcher().didRollbackToSavepoint(this, getDatabaseType());
187                } catch (SQLException e) {
188                        this.rollbackOnly.set(true);
189                        throw databaseException("Unable to roll back to savepoint", e);
190                } catch (RuntimeException | Error e) {
191                        this.rollbackOnly.set(true);
192                        throw e;
193                } finally {
194                        if (connectionLockAcquired)
195                                getConnectionLock().unlock();
196                }
197        }
198
199        /**
200         * Releases the provided transaction savepoint.
201         * <p>
202         * For most application code, prefer {@link #withSavepoint(TransactionalOperation)} or
203         * {@link #withSavepoint(ReturningTransactionalOperation)} so rollback and release cleanup are handled automatically.
204         *
205         * @param savepoint the savepoint to release
206         * @throws IllegalStateException if this transaction has already completed
207         * @since 4.1.0
208         */
209        public void releaseSavepoint(@NonNull Savepoint savepoint) {
210                requireNonNull(savepoint);
211                assertNotCompleted("release a savepoint");
212                releaseSavepointJdbc(savepoint);
213        }
214
215        /**
216         * Performs an operation inside a transaction savepoint.
217         * <p>
218         * If {@code transactionalOperation} completes successfully, the savepoint is released when the driver supports release.
219         * If an exception bubbles out, Pyranid rolls back to the savepoint, attempts to release it, and preserves cleanup failures
220         * as suppressed exceptions on the thrown exception.
221         * <p>
222         * Nested savepoint usage should be stack-like: finish inner savepoints before manually releasing or rolling back outer
223         * savepoints.
224         *
225         * @param transactionalOperation the operation to perform inside a savepoint
226         * @throws IllegalStateException if this transaction has already completed
227         * @since 4.1.0
228         */
229        public void withSavepoint(@NonNull TransactionalOperation transactionalOperation) {
230                requireNonNull(transactionalOperation);
231
232                withSavepoint(() -> {
233                        transactionalOperation.perform();
234                        return Optional.empty();
235                });
236        }
237
238        /**
239         * Performs an operation inside a transaction savepoint and optionally returns a value.
240         * <p>
241         * If {@code transactionalOperation} completes successfully, the savepoint is released when the driver supports release.
242         * If an exception bubbles out, Pyranid rolls back to the savepoint, attempts to release it, and preserves cleanup failures
243         * as suppressed exceptions on the thrown exception.
244         * <p>
245         * Nested savepoint usage should be stack-like: finish inner savepoints before manually releasing or rolling back outer
246         * savepoints.
247         *
248         * @param transactionalOperation the operation to perform inside a savepoint
249         * @param <T>                    the type to be returned
250         * @return the result of the operation
251         * @throws IllegalStateException if this transaction has already completed
252         * @since 4.1.0
253         */
254        @NonNull
255        public <T> Optional<T> withSavepoint(@NonNull ReturningTransactionalOperation<T> transactionalOperation) {
256                requireNonNull(transactionalOperation);
257                assertNotCompleted("run a savepoint operation");
258
259                Savepoint savepoint = createSavepoint();
260
261                try {
262                        Optional<T> returnValue = transactionalOperation.perform();
263
264                        if (returnValue == null)
265                                returnValue = Optional.empty();
266
267                        releaseSavepointAfterSuccess(savepoint);
268                        return returnValue;
269                } catch (RuntimeException e) {
270                        cleanupSavepointAfterFailure(savepoint, e);
271                        throw e;
272                } catch (Error e) {
273                        cleanupSavepointAfterFailure(savepoint, e);
274                        throw e;
275                } catch (Throwable t) {
276                        RuntimeException wrapped = new RuntimeException(t);
277                        cleanupSavepointAfterFailure(savepoint, wrapped);
278                        throw wrapped;
279                }
280        }
281
282        /**
283         * Should this transaction be rolled back upon completion?
284         * <p>
285         * Default value is {@code false}.
286         *
287         * @return {@code true} if this transaction should be rolled back, {@code false} otherwise
288         */
289        @NonNull
290        public Boolean isRollbackOnly() {
291                return this.rollbackOnly.get();
292        }
293
294        /**
295         * Sets whether this transaction should be rolled back upon completion.
296         *
297         * @param rollbackOnly whether to set this transaction to be rollback-only
298         */
299        public void setRollbackOnly(@NonNull Boolean rollbackOnly) {
300                requireNonNull(rollbackOnly);
301                assertNotCompleted("set rollback-only state");
302                this.rollbackOnly.set(rollbackOnly);
303        }
304
305        /**
306         * Adds an operation to the list of operations to be executed when the transaction completes.
307         * <p>
308         * The supplied operation receives {@link TransactionResult#COMMITTED} if commit completed successfully,
309         * {@link TransactionResult#ROLLED_BACK} if the transaction completed on the rollback path before commit was attempted,
310         * or
311         * {@link TransactionResult#IN_DOUBT} if the commit call failed and Pyranid cannot prove whether the database
312         * committed or rolled back.
313         * <p>
314         * If the operation throws, Pyranid wraps the thrown value in a {@link PostTransactionOperationException}. If another
315         * transaction or cleanup failure is already primary, the wrapper is suppressed onto that primary failure; otherwise,
316         * {@link Database#transaction(TransactionalOperation)} throws the wrapper as the primary failure.
317         *
318         * @param postTransactionOperation the post-transaction operation to add
319         */
320        public void addPostTransactionOperation(@NonNull Consumer<TransactionResult> postTransactionOperation) {
321                requireNonNull(postTransactionOperation);
322                assertNotCompleted("add a post-transaction operation");
323                this.postTransactionOperations.add(postTransactionOperation);
324        }
325
326        /**
327         * Removes an operation from the list of operations to be executed when the transaction completes.
328         *
329         * @param postTransactionOperation the post-transaction operation to remove
330         * @return {@code true} if the post-transaction operation was removed, {@code false} otherwise
331         */
332        @NonNull
333        public Boolean removePostTransactionOperation(@NonNull Consumer<TransactionResult> postTransactionOperation) {
334                requireNonNull(postTransactionOperation);
335                assertNotCompleted("remove a post-transaction operation");
336                return this.postTransactionOperations.remove(postTransactionOperation);
337        }
338
339        /**
340         * Gets an unmodifiable list of post-transaction operations.
341         * <p>
342         * To manipulate the list, use {@link #addPostTransactionOperation(Consumer)} and
343         * {@link #removePostTransactionOperation(Consumer)}.
344         *
345         * @return the list of post-transaction operations
346         */
347        @NonNull
348        public List<@NonNull Consumer<TransactionResult>> getPostTransactionOperations() {
349                return Collections.unmodifiableList(this.postTransactionOperations);
350        }
351
352        /**
353         * Get the isolation level for this transaction.
354         *
355         * @return the isolation level
356         */
357        @NonNull
358        public TransactionIsolation getTransactionIsolation() {
359                return this.transactionIsolation;
360        }
361
362        /**
363         * Gets the options used to create this transaction.
364         *
365         * @return transaction options
366         * @since 4.2.0
367         */
368        @NonNull
369        public TransactionOptions getTransactionOptions() {
370                return this.transactionOptions;
371        }
372
373        @NonNull
374        Long id() {
375                return this.id;
376        }
377
378        @NonNull
379        Boolean hasConnection() {
380                getConnectionLock().lock();
381
382                try {
383                        return this.connection != null;
384                } finally {
385                        getConnectionLock().unlock();
386                }
387        }
388
389        @NonNull
390        Boolean isOwnedBy(@NonNull DataSource dataSource) {
391                requireNonNull(dataSource);
392                return this.dataSource == dataSource;
393        }
394
395        void commit() {
396                getConnectionLock().lock();
397
398                try {
399                        if (!hasConnection()) {
400                                LOGGER.finer("Transaction has no connection, so nothing to commit");
401                                return;
402                        }
403
404                        LOGGER.finer("Committing transaction...");
405
406                        long startTime = nanoTime();
407
408                        try {
409                                getConnection().commit();
410                                getMetricsCollectorDispatcher().didCommitPhysicalTransaction(this, getDatabaseType(), Duration.ofNanos(nanoTime() - startTime));
411                                LOGGER.finer("Transaction committed.");
412                        } catch (SQLException e) {
413                                DatabaseException wrapped = databaseException("Unable to commit transaction", e);
414                                getMetricsCollectorDispatcher().didFailToCommitPhysicalTransaction(this, getDatabaseType(), Duration.ofNanos(nanoTime() - startTime), wrapped);
415                                throw wrapped;
416                        }
417                } finally {
418                        getConnectionLock().unlock();
419                }
420        }
421
422        void rollback() {
423                getConnectionLock().lock();
424
425                try {
426                        if (!hasConnection()) {
427                                LOGGER.finer("Transaction has no connection, so nothing to roll back");
428                                return;
429                        }
430
431                        LOGGER.finer("Rolling back transaction...");
432
433                        long startTime = nanoTime();
434
435                        try {
436                                getConnection().rollback();
437                                getMetricsCollectorDispatcher().didRollbackPhysicalTransaction(this, getDatabaseType(), Duration.ofNanos(nanoTime() - startTime));
438                                LOGGER.finer("Transaction rolled back.");
439                        } catch (SQLException e) {
440                                DatabaseException wrapped = databaseException("Unable to roll back transaction", e);
441                                getMetricsCollectorDispatcher().didFailToRollbackPhysicalTransaction(this, getDatabaseType(), Duration.ofNanos(nanoTime() - startTime), wrapped);
442                                throw wrapped;
443                        }
444                } finally {
445                        getConnectionLock().unlock();
446                }
447        }
448
449        /**
450         * The connection associated with this transaction.
451         * <p>
452         * If no connection is associated yet, we ask the {@link DataSource} for one.
453         *
454         * @return The connection associated with this transaction.
455         * @throws DatabaseException if unable to acquire a connection.
456         */
457        @NonNull
458        Connection getConnection() {
459                lockConnectionInterruptibly("get the transaction connection");
460
461                try {
462                        assertNotCompleted("get the transaction connection");
463
464                        if (hasConnection())
465                                return this.connection;
466
467                        long startTime = nanoTime();
468                        getMetricsCollectorDispatcher().willAcquireTransactionConnection(this, getDatabaseType());
469
470                        try {
471                                this.connection = getDataSource().getConnection();
472                        } catch (SQLException e) {
473                                DatabaseException wrapped = databaseException("Unable to acquire database connection", e);
474                                Duration acquisitionDuration = Duration.ofNanos(nanoTime() - startTime);
475                                getMetricsCollectorDispatcher().didFailToAcquireTransactionConnection(this, getDatabaseType(), acquisitionDuration, wrapped);
476                                getMetricsCollectorDispatcher().didFailToBeginPhysicalTransaction(this, getTransactionIsolation(),
477                                                MetricsCollector.PhysicalTransactionBeginFailurePhase.ACQUIRE_CONNECTION, getDatabaseType(), wrapped);
478                                throw wrapped;
479                        } catch (RuntimeException e) {
480                                Duration acquisitionDuration = Duration.ofNanos(nanoTime() - startTime);
481                                getMetricsCollectorDispatcher().didFailToAcquireTransactionConnection(this, getDatabaseType(), acquisitionDuration, e);
482                                getMetricsCollectorDispatcher().didFailToBeginPhysicalTransaction(this, getTransactionIsolation(),
483                                                MetricsCollector.PhysicalTransactionBeginFailurePhase.ACQUIRE_CONNECTION, getDatabaseType(), e);
484                                throw e;
485                        }
486
487                        this.connectionAcquiredAtNanos = nanoTime();
488                        resolveDatabaseType(this.connection);
489                        getMetricsCollectorDispatcher().didAcquireTransactionConnection(this, getDatabaseType(), Duration.ofNanos(this.connectionAcquiredAtNanos - startTime));
490
491                        // Keep track of the initial setting for autocommit since it might need to get changed from "true" to "false" for
492                        // the duration of the transaction and then back to "true" post-transaction.
493                        try {
494                                this.initialAutoCommit = this.connection.getAutoCommit();
495                        } catch (SQLException e) {
496                                DatabaseException wrapped = databaseException("Unable to determine database connection autocommit setting", e);
497                                getMetricsCollectorDispatcher().didFailToBeginPhysicalTransaction(this, getTransactionIsolation(),
498                                                MetricsCollector.PhysicalTransactionBeginFailurePhase.READ_INITIAL_AUTOCOMMIT, getDatabaseType(), wrapped);
499                                throw wrapped;
500                        }
501
502                        // Track initial isolation
503                        try {
504                                this.initialTransactionIsolationJdbcLevel = this.connection.getTransactionIsolation();
505                        } catch (SQLException e) {
506                                DatabaseException wrapped = databaseException("Unable to determine database connection transaction isolation", e);
507                                getMetricsCollectorDispatcher().didFailToBeginPhysicalTransaction(this, getTransactionIsolation(),
508                                                MetricsCollector.PhysicalTransactionBeginFailurePhase.READ_INITIAL_ISOLATION, getDatabaseType(), wrapped);
509                                throw wrapped;
510                        }
511
512                        try {
513                                this.initialReadOnly = this.connection.isReadOnly();
514                        } catch (SQLException e) {
515                                DatabaseException wrapped = databaseException("Unable to determine database connection read-only setting", e);
516                                getMetricsCollectorDispatcher().didFailToBeginPhysicalTransaction(this, getTransactionIsolation(),
517                                                MetricsCollector.PhysicalTransactionBeginFailurePhase.READ_INITIAL_READ_ONLY, getDatabaseType(), wrapped);
518                                throw wrapped;
519                        }
520
521                        Boolean desiredReadOnly = getTransactionOptions().getReadOnly().orElse(null);
522
523                        if (desiredReadOnly != null && !desiredReadOnly.equals(this.initialReadOnly)) {
524                                try {
525                                        this.connection.setReadOnly(desiredReadOnly);
526                                        this.readOnlyWasChanged.set(true);
527                                } catch (SQLException e) {
528                                        DatabaseException wrapped = databaseException(format("Unable to set database connection read-only value to '%s'", desiredReadOnly), e);
529                                        getMetricsCollectorDispatcher().didFailToBeginPhysicalTransaction(this, getTransactionIsolation(),
530                                                        MetricsCollector.PhysicalTransactionBeginFailurePhase.SET_READ_ONLY, getDatabaseType(), wrapped);
531                                        throw wrapped;
532                                }
533                        }
534
535                        // Immediately flip autocommit to false if needed...if initially true, it will get set back to true by Database at
536                        // the end of the transaction
537                        if (this.initialAutoCommit) {
538                                try {
539                                        setAutoCommit(false);
540                                } catch (DatabaseException e) {
541                                        getMetricsCollectorDispatcher().didFailToBeginPhysicalTransaction(this, getTransactionIsolation(),
542                                                        MetricsCollector.PhysicalTransactionBeginFailurePhase.SET_AUTOCOMMIT_FALSE, getDatabaseType(), e);
543                                        throw e;
544                                }
545                        }
546
547                        // Apply requested isolation if not DEFAULT and different from current
548                        TransactionIsolation desiredTransactionIsolation = getTransactionIsolation();
549
550                        if (desiredTransactionIsolation != TransactionIsolation.DEFAULT) {
551                                // Safe; only DEFAULT has a null value
552                                int desiredJdbcLevel = desiredTransactionIsolation.getJdbcLevel().get();
553                                // Apply only if different from current (or current unknown)
554                                if (this.initialTransactionIsolationJdbcLevel == null || this.initialTransactionIsolationJdbcLevel.intValue() != desiredJdbcLevel) {
555                                        try {
556                                                // In the future, we might check supportsTransactionIsolationLevel via DatabaseMetaData first.
557                                                // Probably want to calculate that at Database init time and cache it off
558                                                this.connection.setTransactionIsolation(desiredJdbcLevel);
559                                                this.transactionIsolationWasChanged.set(true);
560                                        } catch (SQLException e) {
561                                                DatabaseException wrapped = databaseException(format("Unable to set transaction isolation to %s", desiredTransactionIsolation.name()), e);
562                                                getMetricsCollectorDispatcher().didFailToBeginPhysicalTransaction(this, getTransactionIsolation(),
563                                                                MetricsCollector.PhysicalTransactionBeginFailurePhase.SET_ISOLATION, getDatabaseType(), wrapped);
564                                                throw wrapped;
565                                        }
566                                }
567                        }
568
569                        getMetricsCollectorDispatcher().didBeginPhysicalTransaction(this, getTransactionIsolation(), getDatabaseType());
570                        return this.connection;
571                } finally {
572                        getConnectionLock().unlock();
573                }
574        }
575
576        void setAutoCommit(@NonNull Boolean autoCommit) {
577                requireNonNull(autoCommit);
578
579                getConnectionLock().lock();
580
581                try {
582                        Connection connection = this.connection;
583
584                        if (connection == null)
585                                throw databaseException("Transaction has no connection", null);
586
587                        try {
588                                connection.setAutoCommit(autoCommit);
589                        } catch (SQLException e) {
590                                throw databaseException(format("Unable to set database connection autocommit value to '%s'", autoCommit), e);
591                        }
592                } finally {
593                        getConnectionLock().unlock();
594                }
595        }
596
597        void restoreTransactionIsolationIfNeeded() {
598                getConnectionLock().lock();
599
600                try {
601                        if (this.connection == null)
602                                return;
603
604                        Integer initialTransactionIsolationJdbcLevel = getInitialTransactionIsolationJdbcLevel().orElse(null);
605
606                        if (getTransactionIsolationWasChanged() && initialTransactionIsolationJdbcLevel != null) {
607                                try {
608                                        this.connection.setTransactionIsolation(initialTransactionIsolationJdbcLevel.intValue());
609                                } catch (SQLException e) {
610                                        throw databaseException("Unable to restore original transaction isolation", e);
611                                } finally {
612                                        this.transactionIsolationWasChanged.set(false);
613                                }
614                        }
615                } finally {
616                        getConnectionLock().unlock();
617                }
618        }
619
620        void restoreReadOnlyIfNeeded() {
621                getConnectionLock().lock();
622
623                try {
624                        if (this.connection == null)
625                                return;
626
627                        Boolean initialReadOnly = getInitialReadOnly().orElse(null);
628
629                        if (getReadOnlyWasChanged() && initialReadOnly != null) {
630                                try {
631                                        this.connection.setReadOnly(initialReadOnly);
632                                } catch (SQLException e) {
633                                        throw databaseException("Unable to restore original read-only setting", e);
634                                } finally {
635                                        this.readOnlyWasChanged.set(false);
636                                }
637                        }
638                } finally {
639                        getConnectionLock().unlock();
640                }
641        }
642
643        @NonNull
644        Long generateId() {
645                return ID_GENERATOR.incrementAndGet();
646        }
647
648        @NonNull
649        Optional<Boolean> getInitialAutoCommit() {
650                return Optional.ofNullable(this.initialAutoCommit);
651        }
652
653        @NonNull
654        Optional<Boolean> getInitialReadOnly() {
655                return Optional.ofNullable(this.initialReadOnly);
656        }
657
658        @NonNull
659        DataSource getDataSource() {
660                return this.dataSource;
661        }
662
663        @NonNull
664        private Optional<Integer> getInitialTransactionIsolationJdbcLevel() {
665                return Optional.ofNullable(this.initialTransactionIsolationJdbcLevel);
666        }
667
668        @NonNull
669        private Boolean getTransactionIsolationWasChanged() {
670                return this.transactionIsolationWasChanged.get();
671        }
672
673        @NonNull
674        private Boolean getReadOnlyWasChanged() {
675                return this.readOnlyWasChanged.get();
676        }
677
678        @NonNull
679        ReentrantLock getConnectionLock() {
680                return this.connectionLock;
681        }
682
683        @NonNull
684        Optional<Connection> getExistingConnection() {
685                getConnectionLock().lock();
686
687                try {
688                        return Optional.ofNullable(this.connection);
689                } finally {
690                        getConnectionLock().unlock();
691                }
692        }
693
694        void clearConnection() {
695                getConnectionLock().lock();
696
697                try {
698                        this.connection = null;
699                } finally {
700                        getConnectionLock().unlock();
701                }
702        }
703
704        void markCompleted() {
705                this.completed.set(true);
706        }
707
708        @NonNull
709        Boolean isCompleted() {
710                return this.completed.get();
711        }
712
713        private void releaseSavepointAfterSuccess(@NonNull Savepoint savepoint) {
714                requireNonNull(savepoint);
715                lockConnectionInterruptibly("release a savepoint");
716
717                try {
718                        getConnection().releaseSavepoint(savepoint);
719                        getMetricsCollectorDispatcher().didReleaseSavepoint(this, getDatabaseType());
720                } catch (SQLFeatureNotSupportedException e) {
721                        // Some drivers support rollback-to-savepoint but not release; successful closures should still succeed.
722                } catch (SQLException e) {
723                        throw databaseException("Unable to release savepoint", e);
724                } finally {
725                        getConnectionLock().unlock();
726                }
727        }
728
729        private void cleanupSavepointAfterFailure(@NonNull Savepoint savepoint,
730                                                                                                                                                                                @NonNull Throwable primary) {
731                requireNonNull(savepoint);
732                requireNonNull(primary);
733
734                boolean rollbackConnectionLockAcquired = false;
735
736                try {
737                        lockConnectionInterruptibly("roll back to a savepoint");
738                        rollbackConnectionLockAcquired = true;
739                        getConnection().rollback(savepoint);
740                        getMetricsCollectorDispatcher().didRollbackToSavepoint(this, getDatabaseType());
741                } catch (Throwable rollbackException) {
742                        this.rollbackOnly.set(true);
743                        primary.addSuppressed(databaseException("Unable to roll back to savepoint", rollbackException));
744                } finally {
745                        if (rollbackConnectionLockAcquired)
746                                getConnectionLock().unlock();
747                }
748
749                boolean releaseConnectionLockAcquired = false;
750
751                try {
752                        lockConnectionInterruptibly("release a savepoint");
753                        releaseConnectionLockAcquired = true;
754                        getConnection().releaseSavepoint(savepoint);
755                        getMetricsCollectorDispatcher().didReleaseSavepoint(this, getDatabaseType());
756                } catch (SQLFeatureNotSupportedException e) {
757                        // Some drivers support rollback-to-savepoint but not release.
758                } catch (Throwable releaseException) {
759                        primary.addSuppressed(databaseException("Unable to release savepoint", releaseException));
760                } finally {
761                        if (releaseConnectionLockAcquired)
762                                getConnectionLock().unlock();
763                }
764        }
765
766        private void releaseSavepointJdbc(@NonNull Savepoint savepoint) {
767                requireNonNull(savepoint);
768                lockConnectionInterruptibly("release a savepoint");
769
770                try {
771                        getConnection().releaseSavepoint(savepoint);
772                        getMetricsCollectorDispatcher().didReleaseSavepoint(this, getDatabaseType());
773                } catch (SQLException e) {
774                        throw databaseException("Unable to release savepoint", e);
775                } finally {
776                        getConnectionLock().unlock();
777                }
778        }
779
780        private void lockConnectionInterruptibly(@NonNull String operation) {
781                requireNonNull(operation);
782
783                try {
784                        getConnectionLock().lockInterruptibly();
785                } catch (InterruptedException e) {
786                        Thread.currentThread().interrupt();
787                        throw databaseException(format("Interrupted while waiting to %s", operation), e);
788                }
789        }
790
791        private void resolveDatabaseType(@NonNull Connection connection) {
792                requireNonNull(connection);
793
794                if (this.databaseType != DatabaseType.GENERIC)
795                        return;
796
797                try {
798                        this.databaseType = requireNonNull(this.databaseTypeResolver.apply(connection));
799                } catch (Throwable t) {
800                        LOGGER.log(Level.FINE, "Unable to determine database type from transaction connection", t);
801                }
802        }
803
804        @NonNull
805        private DatabaseException databaseException(@NonNull String message,
806                                                                                                                                                @Nullable Throwable cause) {
807                requireNonNull(message);
808
809                DatabaseDialect databaseDialect = getDatabaseType() == DatabaseType.GENERIC
810                                ? DatabaseDialect.forExceptionCause(cause)
811                                : getDatabaseType().dialect();
812                return new DatabaseException(message, cause, databaseDialect);
813        }
814
815        @NonNull
816        MetricsCollectorDispatcher getMetricsCollectorDispatcher() {
817                return this.metricsCollectorDispatcher;
818        }
819
820        @NonNull
821        DatabaseType getDatabaseType() {
822                return this.databaseType;
823        }
824
825        @NonNull
826        Optional<Long> getConnectionAcquiredAtNanos() {
827                return Optional.ofNullable(this.connectionAcquiredAtNanos);
828        }
829
830        private void assertNotCompleted(@NonNull String operation) {
831                requireNonNull(operation);
832
833                if (isCompleted())
834                        throw new IllegalStateException(format("Transaction %s has already completed and cannot %s", id(), operation));
835        }
836}