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