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.Savepoint;
027import java.util.Collections;
028import java.util.List;
029import java.util.Optional;
030import java.util.concurrent.CopyOnWriteArrayList;
031import java.util.concurrent.atomic.AtomicBoolean;
032import java.util.concurrent.atomic.AtomicLong;
033import java.util.concurrent.locks.ReentrantLock;
034import java.util.function.Consumer;
035import java.util.logging.Logger;
036
037import static java.lang.String.format;
038import static java.util.Objects.requireNonNull;
039
040/**
041 * Represents a database transaction.
042 * <p>
043 * Note that commit and rollback operations are controlled internally by {@link Database}.
044 *
045 * @author <a href="https://www.revetkn.com">Mark Allen</a>
046 * @since 1.0.0
047 */
048@ThreadSafe
049public final class Transaction {
050        @NonNull
051        private static final AtomicLong ID_GENERATOR;
052
053        static {
054                ID_GENERATOR = new AtomicLong(0);
055        }
056
057        @NonNull
058        private final Long id;
059        @NonNull
060        private final DataSource dataSource;
061        @NonNull
062        private final TransactionIsolation transactionIsolation;
063        @NonNull
064        private final List<@NonNull Consumer<TransactionResult>> postTransactionOperations;
065        @NonNull
066        private final ReentrantLock connectionLock;
067        @NonNull
068        private final Logger logger;
069        @NonNull
070        private final Long ownerThreadId;
071
072        @NonNull
073        private final AtomicBoolean rollbackOnly;
074        @Nullable
075        private volatile Connection connection;
076        @Nullable
077        private volatile Boolean initialAutoCommit;
078        @Nullable
079        private volatile Integer initialTransactionIsolationJdbcLevel;
080        @NonNull
081        private final AtomicBoolean transactionIsolationWasChanged;
082
083        Transaction(@NonNull DataSource dataSource,
084                                                        @NonNull TransactionIsolation transactionIsolation) {
085                requireNonNull(dataSource);
086                requireNonNull(transactionIsolation);
087
088                this.id = generateId();
089                this.dataSource = dataSource;
090                this.transactionIsolation = transactionIsolation;
091                this.connection = null;
092                this.rollbackOnly = new AtomicBoolean(false);
093                this.initialAutoCommit = null;
094                this.transactionIsolationWasChanged = new AtomicBoolean(false);
095                this.postTransactionOperations = new CopyOnWriteArrayList();
096                this.connectionLock = new ReentrantLock();
097                this.logger = Logger.getLogger(Transaction.class.getName());
098                this.ownerThreadId = Thread.currentThread().getId();
099        }
100
101        @Override
102        @NonNull
103        public String toString() {
104                return format("%s{id=%s, transactionIsolation=%s, hasConnection=%s, isRollbackOnly=%s}",
105                                getClass().getSimpleName(), id(), getTransactionIsolation(), hasConnection(), isRollbackOnly());
106        }
107
108        /**
109         * Creates a transaction savepoint that can be rolled back to via {@link #rollback(Savepoint)}.
110         *
111         * @return a transaction savepoint
112         */
113        @NonNull
114        public Savepoint createSavepoint() {
115                try {
116                        return getConnection().setSavepoint();
117                } catch (SQLException e) {
118                        throw new DatabaseException("Unable to create savepoint", e);
119                }
120        }
121
122        /**
123         * Rolls back to the provided transaction savepoint.
124         *
125         * @param savepoint the savepoint to roll back to
126         */
127        public void rollback(@NonNull Savepoint savepoint) {
128                requireNonNull(savepoint);
129
130                try {
131                        getConnection().rollback(savepoint);
132                } catch (SQLException e) {
133                        throw new DatabaseException("Unable to roll back to savepoint", e);
134                }
135        }
136
137        /**
138         * Should this transaction be rolled back upon completion?
139         * <p>
140         * Default value is {@code false}.
141         *
142         * @return {@code true} if this transaction should be rolled back, {@code false} otherwise
143         */
144        @NonNull
145        public Boolean isRollbackOnly() {
146                return this.rollbackOnly.get();
147        }
148
149        /**
150         * Sets whether this transaction should be rolled back upon completion.
151         *
152         * @param rollbackOnly whether to set this transaction to be rollback-only
153         */
154        public void setRollbackOnly(@NonNull Boolean rollbackOnly) {
155                requireNonNull(rollbackOnly);
156                this.rollbackOnly.set(rollbackOnly);
157        }
158
159        /**
160         * Adds an operation to the list of operations to be executed when the transaction completes.
161         *
162         * @param postTransactionOperation the post-transaction operation to add
163         */
164        public void addPostTransactionOperation(@NonNull Consumer<TransactionResult> postTransactionOperation) {
165                requireNonNull(postTransactionOperation);
166                this.postTransactionOperations.add(postTransactionOperation);
167        }
168
169        /**
170         * Removes an operation from the list of operations to be executed when the transaction completes.
171         *
172         * @param postTransactionOperation the post-transaction operation to remove
173         * @return {@code true} if the post-transaction operation was removed, {@code false} otherwise
174         */
175        @NonNull
176        public Boolean removePostTransactionOperation(@NonNull Consumer<TransactionResult> postTransactionOperation) {
177                requireNonNull(postTransactionOperation);
178                return this.postTransactionOperations.remove(postTransactionOperation);
179        }
180
181        /**
182         * Gets an unmodifiable list of post-transaction operations.
183         * <p>
184         * To manipulate the list, use {@link #addPostTransactionOperation(Consumer)} and
185         * {@link #removePostTransactionOperation(Consumer)}.
186         *
187         * @return the list of post-transaction operations
188         */
189        @NonNull
190        public List<@NonNull Consumer<TransactionResult>> getPostTransactionOperations() {
191                return Collections.unmodifiableList(this.postTransactionOperations);
192        }
193
194        /**
195         * Get the isolation level for this transaction.
196         *
197         * @return the isolation level
198         */
199        @NonNull
200        public TransactionIsolation getTransactionIsolation() {
201                return this.transactionIsolation;
202        }
203
204        @NonNull
205        Long id() {
206                return this.id;
207        }
208
209        @NonNull
210        Boolean hasConnection() {
211                getConnectionLock().lock();
212
213                try {
214                        return this.connection != null;
215                } finally {
216                        getConnectionLock().unlock();
217                }
218        }
219
220        @NonNull
221        Boolean isOwnedByCurrentThread() {
222                return Thread.currentThread().getId() == this.ownerThreadId;
223        }
224
225        void commit() {
226                getConnectionLock().lock();
227
228                try {
229                        if (!hasConnection()) {
230                                logger.finer("Transaction has no connection, so nothing to commit");
231                                return;
232                        }
233
234                        logger.finer("Committing transaction...");
235
236                        try {
237                                getConnection().commit();
238                                logger.finer("Transaction committed.");
239                        } catch (SQLException e) {
240                                throw new DatabaseException("Unable to commit transaction", e);
241                        }
242                } finally {
243                        getConnectionLock().unlock();
244                }
245        }
246
247        void rollback() {
248                getConnectionLock().lock();
249
250                try {
251                        if (!hasConnection()) {
252                                logger.finer("Transaction has no connection, so nothing to roll back");
253                                return;
254                        }
255
256                        logger.finer("Rolling back transaction...");
257
258                        try {
259                                getConnection().rollback();
260                                logger.finer("Transaction rolled back.");
261                        } catch (SQLException e) {
262                                throw new DatabaseException("Unable to roll back transaction", e);
263                        }
264                } finally {
265                        getConnectionLock().unlock();
266                }
267        }
268
269        /**
270         * The connection associated with this transaction.
271         * <p>
272         * If no connection is associated yet, we ask the {@link DataSource} for one.
273         *
274         * @return The connection associated with this transaction.
275         * @throws DatabaseException if unable to acquire a connection.
276         */
277        @NonNull
278        Connection getConnection() {
279                getConnectionLock().lock();
280
281                try {
282                        if (hasConnection())
283                                return this.connection;
284
285                        try {
286                                this.connection = getDataSource().getConnection();
287                        } catch (SQLException e) {
288                                throw new DatabaseException("Unable to acquire database connection", e);
289                        }
290
291                        // Keep track of the initial setting for autocommit since it might need to get changed from "true" to "false" for
292                        // the duration of the transaction and then back to "true" post-transaction.
293                        try {
294                                this.initialAutoCommit = this.connection.getAutoCommit();
295                        } catch (SQLException e) {
296                                throw new DatabaseException("Unable to determine database connection autocommit setting", e);
297                        }
298
299                        // Track initial isolation
300                        try {
301                                this.initialTransactionIsolationJdbcLevel = this.connection.getTransactionIsolation();
302                        } catch (SQLException e) {
303                                throw new DatabaseException("Unable to determine database connection transaction isolation", e);
304                        }
305
306                        // Immediately flip autocommit to false if needed...if initially true, it will get set back to true by Database at
307                        // the end of the transaction
308                        if (this.initialAutoCommit)
309                                setAutoCommit(false);
310
311                        // Apply requested isolation if not DEFAULT and different from current
312                        TransactionIsolation desiredTransactionIsolation = getTransactionIsolation();
313
314                        if (desiredTransactionIsolation != TransactionIsolation.DEFAULT) {
315                                // Safe; only DEFAULT has a null value
316                                int desiredJdbcLevel = desiredTransactionIsolation.getJdbcLevel().get();
317                                // Apply only if different from current (or current unknown)
318                                if (this.initialTransactionIsolationJdbcLevel == null || this.initialTransactionIsolationJdbcLevel.intValue() != desiredJdbcLevel) {
319                                        try {
320                                                // In the future, we might check supportsTransactionIsolationLevel via DatabaseMetaData first.
321                                                // Probably want to calculate that at Database init time and cache it off
322                                                this.connection.setTransactionIsolation(desiredJdbcLevel);
323                                                this.transactionIsolationWasChanged.set(true);
324                                        } catch (SQLException e) {
325                                                throw new DatabaseException(format("Unable to set transaction isolation to %s", desiredTransactionIsolation.name()), e);
326                                        }
327                                }
328                        }
329
330                        return this.connection;
331                } finally {
332                        getConnectionLock().unlock();
333                }
334        }
335
336        void setAutoCommit(@NonNull Boolean autoCommit) {
337                requireNonNull(autoCommit);
338
339                getConnectionLock().lock();
340
341                try {
342                        try {
343                                getConnection().setAutoCommit(autoCommit);
344                        } catch (SQLException e) {
345                                throw new DatabaseException(format("Unable to set database connection autocommit value to '%s'", autoCommit), e);
346                        }
347                } finally {
348                        getConnectionLock().unlock();
349                }
350        }
351
352        void restoreTransactionIsolationIfNeeded() {
353                getConnectionLock().lock();
354
355                try {
356                        if (this.connection == null)
357                                return;
358
359                        Integer initialTransactionIsolationJdbcLevel = getInitialTransactionIsolationJdbcLevel().orElse(null);
360
361                        if (getTransactionIsolationWasChanged() && initialTransactionIsolationJdbcLevel != null) {
362                                try {
363                                        this.connection.setTransactionIsolation(initialTransactionIsolationJdbcLevel.intValue());
364                                } catch (SQLException e) {
365                                        throw new DatabaseException("Unable to restore original transaction isolation", e);
366                                } finally {
367                                        this.transactionIsolationWasChanged.set(false);
368                                }
369                        }
370                } finally {
371                        getConnectionLock().unlock();
372                }
373        }
374
375        @NonNull
376        Long generateId() {
377                return ID_GENERATOR.incrementAndGet();
378        }
379
380        @NonNull
381        Optional<Boolean> getInitialAutoCommit() {
382                return Optional.ofNullable(this.initialAutoCommit);
383        }
384
385        @NonNull
386        DataSource getDataSource() {
387                return this.dataSource;
388        }
389
390        @NonNull
391        protected Optional<Integer> getInitialTransactionIsolationJdbcLevel() {
392                return Optional.ofNullable(this.initialTransactionIsolationJdbcLevel);
393        }
394
395        @NonNull
396        protected Boolean getTransactionIsolationWasChanged() {
397                return this.transactionIsolationWasChanged.get();
398        }
399
400        @NonNull
401        protected ReentrantLock getConnectionLock() {
402                return this.connectionLock;
403        }
404}