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.util.Collections;
029import java.util.List;
030import java.util.Optional;
031import java.util.concurrent.CopyOnWriteArrayList;
032import java.util.concurrent.atomic.AtomicBoolean;
033import java.util.concurrent.atomic.AtomicLong;
034import java.util.concurrent.locks.ReentrantLock;
035import java.util.function.Consumer;
036import java.util.logging.Logger;
037
038import static java.lang.String.format;
039import static java.util.Objects.requireNonNull;
040
041/**
042 * Represents a database transaction.
043 * <p>
044 * Note that commit and rollback operations are controlled internally by {@link Database}.
045 *
046 * @author <a href="https://www.revetkn.com">Mark Allen</a>
047 * @since 1.0.0
048 */
049@ThreadSafe
050public final class Transaction {
051        @NonNull
052        private static final AtomicLong ID_GENERATOR;
053
054        static {
055                ID_GENERATOR = new AtomicLong(0);
056        }
057
058        @NonNull
059        private final Long id;
060        @NonNull
061        private final DataSource dataSource;
062        @NonNull
063        private final TransactionIsolation transactionIsolation;
064        @NonNull
065        private final List<@NonNull Consumer<TransactionResult>> postTransactionOperations;
066        @NonNull
067        private final ReentrantLock connectionLock;
068        @NonNull
069        private final Logger logger;
070        @NonNull
071        private final Long ownerThreadId;
072
073        @NonNull
074        private final AtomicBoolean rollbackOnly;
075        @NonNull
076        private final AtomicBoolean completed;
077        @Nullable
078        private volatile Connection connection;
079        @Nullable
080        private volatile Boolean initialAutoCommit;
081        @Nullable
082        private volatile Integer initialTransactionIsolationJdbcLevel;
083        @NonNull
084        private final AtomicBoolean transactionIsolationWasChanged;
085
086        Transaction(@NonNull DataSource dataSource,
087                                                        @NonNull TransactionIsolation transactionIsolation) {
088                requireNonNull(dataSource);
089                requireNonNull(transactionIsolation);
090
091                this.id = generateId();
092                this.dataSource = dataSource;
093                this.transactionIsolation = transactionIsolation;
094                this.connection = null;
095                this.rollbackOnly = new AtomicBoolean(false);
096                this.completed = new AtomicBoolean(false);
097                this.initialAutoCommit = null;
098                this.transactionIsolationWasChanged = new AtomicBoolean(false);
099                this.postTransactionOperations = new CopyOnWriteArrayList();
100                this.connectionLock = new ReentrantLock();
101                this.logger = Logger.getLogger(Transaction.class.getName());
102                this.ownerThreadId = Thread.currentThread().getId();
103        }
104
105        @Override
106        @NonNull
107        public String toString() {
108                return format("%s{id=%s, transactionIsolation=%s, hasConnection=%s, isRollbackOnly=%s}",
109                                getClass().getSimpleName(), id(), getTransactionIsolation(), hasConnection(), isRollbackOnly());
110        }
111
112        /**
113         * Creates a transaction savepoint that can be rolled back to via {@link #rollback(Savepoint)}.
114         * <p>
115         * For most application code, prefer {@link #withSavepoint(TransactionalOperation)} or
116         * {@link #withSavepoint(ReturningTransactionalOperation)} so rollback and release cleanup are handled automatically.
117         *
118         * @return a transaction savepoint
119         * @throws IllegalStateException if this transaction has already completed
120         */
121        @NonNull
122        public Savepoint createSavepoint() {
123                assertNotCompleted("create a savepoint");
124
125                try {
126                        return getConnection().setSavepoint();
127                } catch (SQLException e) {
128                        throw new DatabaseException("Unable to create savepoint", e);
129                }
130        }
131
132        /**
133         * Rolls back to the provided transaction savepoint.
134         *
135         * @param savepoint the savepoint to roll back to
136         * @throws IllegalStateException if this transaction has already completed
137         */
138        public void rollback(@NonNull Savepoint savepoint) {
139                requireNonNull(savepoint);
140                assertNotCompleted("roll back to a savepoint");
141
142                try {
143                        getConnection().rollback(savepoint);
144                } catch (SQLException e) {
145                        throw new DatabaseException("Unable to roll back to savepoint", e);
146                }
147        }
148
149        /**
150         * Releases the provided transaction savepoint.
151         * <p>
152         * For most application code, prefer {@link #withSavepoint(TransactionalOperation)} or
153         * {@link #withSavepoint(ReturningTransactionalOperation)} so rollback and release cleanup are handled automatically.
154         *
155         * @param savepoint the savepoint to release
156         * @throws IllegalStateException if this transaction has already completed
157         * @since 4.1.0
158         */
159        public void releaseSavepoint(@NonNull Savepoint savepoint) {
160                requireNonNull(savepoint);
161                assertNotCompleted("release a savepoint");
162                releaseSavepointJdbc(savepoint);
163        }
164
165        /**
166         * Performs an operation inside a transaction savepoint.
167         * <p>
168         * If {@code transactionalOperation} completes successfully, the savepoint is released when the driver supports release.
169         * If an exception bubbles out, Pyranid rolls back to the savepoint, attempts to release it, and preserves cleanup failures
170         * as suppressed exceptions on the thrown exception.
171         * <p>
172         * Nested savepoint usage should be stack-like: finish inner savepoints before manually releasing or rolling back outer
173         * savepoints.
174         *
175         * @param transactionalOperation the operation to perform inside a savepoint
176         * @throws IllegalStateException if this transaction has already completed
177         * @since 4.1.0
178         */
179        public void withSavepoint(@NonNull TransactionalOperation transactionalOperation) {
180                requireNonNull(transactionalOperation);
181
182                withSavepoint(() -> {
183                        transactionalOperation.perform();
184                        return Optional.empty();
185                });
186        }
187
188        /**
189         * Performs an operation inside a transaction savepoint and optionally returns a value.
190         * <p>
191         * If {@code transactionalOperation} completes successfully, the savepoint is released when the driver supports release.
192         * If an exception bubbles out, Pyranid rolls back to the savepoint, attempts to release it, and preserves cleanup failures
193         * as suppressed exceptions on the thrown exception.
194         * <p>
195         * Nested savepoint usage should be stack-like: finish inner savepoints before manually releasing or rolling back outer
196         * savepoints.
197         *
198         * @param transactionalOperation the operation to perform inside a savepoint
199         * @param <T>                    the type to be returned
200         * @return the result of the operation
201         * @throws IllegalStateException if this transaction has already completed
202         * @since 4.1.0
203         */
204        @NonNull
205        public <T> Optional<T> withSavepoint(@NonNull ReturningTransactionalOperation<T> transactionalOperation) {
206                requireNonNull(transactionalOperation);
207                assertNotCompleted("run a savepoint operation");
208
209                Savepoint savepoint = createSavepoint();
210
211                try {
212                        Optional<T> returnValue = transactionalOperation.perform();
213
214                        if (returnValue == null)
215                                returnValue = Optional.empty();
216
217                        releaseSavepointAfterSuccess(savepoint);
218                        return returnValue;
219                } catch (RuntimeException e) {
220                        cleanupSavepointAfterFailure(savepoint, e);
221                        throw e;
222                } catch (Error e) {
223                        cleanupSavepointAfterFailure(savepoint, e);
224                        throw e;
225                } catch (Throwable t) {
226                        RuntimeException wrapped = new RuntimeException(t);
227                        cleanupSavepointAfterFailure(savepoint, wrapped);
228                        throw wrapped;
229                }
230        }
231
232        /**
233         * Should this transaction be rolled back upon completion?
234         * <p>
235         * Default value is {@code false}.
236         *
237         * @return {@code true} if this transaction should be rolled back, {@code false} otherwise
238         */
239        @NonNull
240        public Boolean isRollbackOnly() {
241                return this.rollbackOnly.get();
242        }
243
244        /**
245         * Sets whether this transaction should be rolled back upon completion.
246         *
247         * @param rollbackOnly whether to set this transaction to be rollback-only
248         */
249        public void setRollbackOnly(@NonNull Boolean rollbackOnly) {
250                requireNonNull(rollbackOnly);
251                assertNotCompleted("set rollback-only state");
252                this.rollbackOnly.set(rollbackOnly);
253        }
254
255        /**
256         * Adds an operation to the list of operations to be executed when the transaction completes.
257         *
258         * @param postTransactionOperation the post-transaction operation to add
259         */
260        public void addPostTransactionOperation(@NonNull Consumer<TransactionResult> postTransactionOperation) {
261                requireNonNull(postTransactionOperation);
262                assertNotCompleted("add a post-transaction operation");
263                this.postTransactionOperations.add(postTransactionOperation);
264        }
265
266        /**
267         * Removes an operation from the list of operations to be executed when the transaction completes.
268         *
269         * @param postTransactionOperation the post-transaction operation to remove
270         * @return {@code true} if the post-transaction operation was removed, {@code false} otherwise
271         */
272        @NonNull
273        public Boolean removePostTransactionOperation(@NonNull Consumer<TransactionResult> postTransactionOperation) {
274                requireNonNull(postTransactionOperation);
275                assertNotCompleted("remove a post-transaction operation");
276                return this.postTransactionOperations.remove(postTransactionOperation);
277        }
278
279        /**
280         * Gets an unmodifiable list of post-transaction operations.
281         * <p>
282         * To manipulate the list, use {@link #addPostTransactionOperation(Consumer)} and
283         * {@link #removePostTransactionOperation(Consumer)}.
284         *
285         * @return the list of post-transaction operations
286         */
287        @NonNull
288        public List<@NonNull Consumer<TransactionResult>> getPostTransactionOperations() {
289                return Collections.unmodifiableList(this.postTransactionOperations);
290        }
291
292        /**
293         * Get the isolation level for this transaction.
294         *
295         * @return the isolation level
296         */
297        @NonNull
298        public TransactionIsolation getTransactionIsolation() {
299                return this.transactionIsolation;
300        }
301
302        @NonNull
303        Long id() {
304                return this.id;
305        }
306
307        @NonNull
308        Boolean hasConnection() {
309                getConnectionLock().lock();
310
311                try {
312                        return this.connection != null;
313                } finally {
314                        getConnectionLock().unlock();
315                }
316        }
317
318        @NonNull
319        Boolean isOwnedByCurrentThread() {
320                return Thread.currentThread().getId() == this.ownerThreadId;
321        }
322
323        void commit() {
324                getConnectionLock().lock();
325
326                try {
327                        if (!hasConnection()) {
328                                logger.finer("Transaction has no connection, so nothing to commit");
329                                return;
330                        }
331
332                        logger.finer("Committing transaction...");
333
334                        try {
335                                getConnection().commit();
336                                logger.finer("Transaction committed.");
337                        } catch (SQLException e) {
338                                throw new DatabaseException("Unable to commit transaction", e);
339                        }
340                } finally {
341                        getConnectionLock().unlock();
342                }
343        }
344
345        void rollback() {
346                getConnectionLock().lock();
347
348                try {
349                        if (!hasConnection()) {
350                                logger.finer("Transaction has no connection, so nothing to roll back");
351                                return;
352                        }
353
354                        logger.finer("Rolling back transaction...");
355
356                        try {
357                                getConnection().rollback();
358                                logger.finer("Transaction rolled back.");
359                        } catch (SQLException e) {
360                                throw new DatabaseException("Unable to roll back transaction", e);
361                        }
362                } finally {
363                        getConnectionLock().unlock();
364                }
365        }
366
367        /**
368         * The connection associated with this transaction.
369         * <p>
370         * If no connection is associated yet, we ask the {@link DataSource} for one.
371         *
372         * @return The connection associated with this transaction.
373         * @throws DatabaseException if unable to acquire a connection.
374         */
375        @NonNull
376        Connection getConnection() {
377                assertNotCompleted("get the transaction connection");
378
379                getConnectionLock().lock();
380
381                try {
382                        if (hasConnection())
383                                return this.connection;
384
385                        try {
386                                this.connection = getDataSource().getConnection();
387                        } catch (SQLException e) {
388                                throw new DatabaseException("Unable to acquire database connection", e);
389                        }
390
391                        // Keep track of the initial setting for autocommit since it might need to get changed from "true" to "false" for
392                        // the duration of the transaction and then back to "true" post-transaction.
393                        try {
394                                this.initialAutoCommit = this.connection.getAutoCommit();
395                        } catch (SQLException e) {
396                                throw new DatabaseException("Unable to determine database connection autocommit setting", e);
397                        }
398
399                        // Track initial isolation
400                        try {
401                                this.initialTransactionIsolationJdbcLevel = this.connection.getTransactionIsolation();
402                        } catch (SQLException e) {
403                                throw new DatabaseException("Unable to determine database connection transaction isolation", e);
404                        }
405
406                        // Immediately flip autocommit to false if needed...if initially true, it will get set back to true by Database at
407                        // the end of the transaction
408                        if (this.initialAutoCommit)
409                                setAutoCommit(false);
410
411                        // Apply requested isolation if not DEFAULT and different from current
412                        TransactionIsolation desiredTransactionIsolation = getTransactionIsolation();
413
414                        if (desiredTransactionIsolation != TransactionIsolation.DEFAULT) {
415                                // Safe; only DEFAULT has a null value
416                                int desiredJdbcLevel = desiredTransactionIsolation.getJdbcLevel().get();
417                                // Apply only if different from current (or current unknown)
418                                if (this.initialTransactionIsolationJdbcLevel == null || this.initialTransactionIsolationJdbcLevel.intValue() != desiredJdbcLevel) {
419                                        try {
420                                                // In the future, we might check supportsTransactionIsolationLevel via DatabaseMetaData first.
421                                                // Probably want to calculate that at Database init time and cache it off
422                                                this.connection.setTransactionIsolation(desiredJdbcLevel);
423                                                this.transactionIsolationWasChanged.set(true);
424                                        } catch (SQLException e) {
425                                                throw new DatabaseException(format("Unable to set transaction isolation to %s", desiredTransactionIsolation.name()), e);
426                                        }
427                                }
428                        }
429
430                        return this.connection;
431                } finally {
432                        getConnectionLock().unlock();
433                }
434        }
435
436        void setAutoCommit(@NonNull Boolean autoCommit) {
437                requireNonNull(autoCommit);
438
439                getConnectionLock().lock();
440
441                try {
442                        Connection connection = this.connection;
443
444                        if (connection == null)
445                                throw new DatabaseException("Transaction has no connection");
446
447                        try {
448                                connection.setAutoCommit(autoCommit);
449                        } catch (SQLException e) {
450                                throw new DatabaseException(format("Unable to set database connection autocommit value to '%s'", autoCommit), e);
451                        }
452                } finally {
453                        getConnectionLock().unlock();
454                }
455        }
456
457        void restoreTransactionIsolationIfNeeded() {
458                getConnectionLock().lock();
459
460                try {
461                        if (this.connection == null)
462                                return;
463
464                        Integer initialTransactionIsolationJdbcLevel = getInitialTransactionIsolationJdbcLevel().orElse(null);
465
466                        if (getTransactionIsolationWasChanged() && initialTransactionIsolationJdbcLevel != null) {
467                                try {
468                                        this.connection.setTransactionIsolation(initialTransactionIsolationJdbcLevel.intValue());
469                                } catch (SQLException e) {
470                                        throw new DatabaseException("Unable to restore original transaction isolation", e);
471                                } finally {
472                                        this.transactionIsolationWasChanged.set(false);
473                                }
474                        }
475                } finally {
476                        getConnectionLock().unlock();
477                }
478        }
479
480        @NonNull
481        Long generateId() {
482                return ID_GENERATOR.incrementAndGet();
483        }
484
485        @NonNull
486        Optional<Boolean> getInitialAutoCommit() {
487                return Optional.ofNullable(this.initialAutoCommit);
488        }
489
490        @NonNull
491        DataSource getDataSource() {
492                return this.dataSource;
493        }
494
495        @NonNull
496        protected Optional<Integer> getInitialTransactionIsolationJdbcLevel() {
497                return Optional.ofNullable(this.initialTransactionIsolationJdbcLevel);
498        }
499
500        @NonNull
501        protected Boolean getTransactionIsolationWasChanged() {
502                return this.transactionIsolationWasChanged.get();
503        }
504
505        @NonNull
506        protected ReentrantLock getConnectionLock() {
507                return this.connectionLock;
508        }
509
510        @NonNull
511        Optional<Connection> getExistingConnection() {
512                getConnectionLock().lock();
513
514                try {
515                        return Optional.ofNullable(this.connection);
516                } finally {
517                        getConnectionLock().unlock();
518                }
519        }
520
521        void clearConnection() {
522                getConnectionLock().lock();
523
524                try {
525                        this.connection = null;
526                } finally {
527                        getConnectionLock().unlock();
528                }
529        }
530
531        void markCompleted() {
532                this.completed.set(true);
533        }
534
535        @NonNull
536        Boolean isCompleted() {
537                return this.completed.get();
538        }
539
540        private void releaseSavepointAfterSuccess(@NonNull Savepoint savepoint) {
541                requireNonNull(savepoint);
542
543                try {
544                        getConnection().releaseSavepoint(savepoint);
545                } catch (SQLFeatureNotSupportedException e) {
546                        // Some drivers support rollback-to-savepoint but not release; successful closures should still succeed.
547                } catch (SQLException e) {
548                        throw new DatabaseException("Unable to release savepoint", e);
549                }
550        }
551
552        private void cleanupSavepointAfterFailure(@NonNull Savepoint savepoint,
553                                                                                                                                                                                @NonNull Throwable primary) {
554                requireNonNull(savepoint);
555                requireNonNull(primary);
556
557                try {
558                        getConnection().rollback(savepoint);
559                } catch (Throwable rollbackException) {
560                        primary.addSuppressed(new DatabaseException("Unable to roll back to savepoint", rollbackException));
561                }
562
563                try {
564                        getConnection().releaseSavepoint(savepoint);
565                } catch (SQLFeatureNotSupportedException e) {
566                        // Some drivers support rollback-to-savepoint but not release.
567                } catch (Throwable releaseException) {
568                        primary.addSuppressed(new DatabaseException("Unable to release savepoint", releaseException));
569                }
570        }
571
572        private void releaseSavepointJdbc(@NonNull Savepoint savepoint) {
573                requireNonNull(savepoint);
574
575                try {
576                        getConnection().releaseSavepoint(savepoint);
577                } catch (SQLException e) {
578                        throw new DatabaseException("Unable to release savepoint", e);
579                }
580        }
581
582        private void assertNotCompleted(@NonNull String operation) {
583                requireNonNull(operation);
584
585                if (isCompleted())
586                        throw new IllegalStateException(format("Transaction %s has already completed and cannot %s", id(), operation));
587        }
588}