001/* 002 * Copyright 2015-2022 Transmogrify LLC, 2022-2024 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 javax.annotation.Nonnull; 020import javax.annotation.Nullable; 021import javax.annotation.concurrent.ThreadSafe; 022import javax.sql.DataSource; 023import java.sql.Connection; 024import java.sql.SQLException; 025import java.sql.Savepoint; 026import java.util.Collections; 027import java.util.List; 028import java.util.Optional; 029import java.util.concurrent.CopyOnWriteArrayList; 030import java.util.concurrent.atomic.AtomicLong; 031import java.util.concurrent.locks.ReentrantLock; 032import java.util.function.Consumer; 033import java.util.logging.Logger; 034 035import static java.lang.String.format; 036import static java.util.Objects.requireNonNull; 037 038/** 039 * Represents a database transaction. 040 * <p> 041 * Note that commit and rollback operations are controlled internally by {@link Database}. 042 * 043 * @author <a href="https://www.revetkn.com">Mark Allen</a> 044 * @since 1.0.0 045 */ 046@ThreadSafe 047public class Transaction { 048 @Nonnull 049 private static final AtomicLong ID_GENERATOR; 050 051 static { 052 ID_GENERATOR = new AtomicLong(0); 053 } 054 055 @Nonnull 056 private final Long id; 057 @Nonnull 058 private final DataSource dataSource; 059 @Nonnull 060 private final TransactionIsolation transactionIsolation; 061 @Nonnull 062 private final List<Consumer<TransactionResult>> postTransactionOperations; 063 @Nonnull 064 private final ReentrantLock connectionLock; 065 @Nonnull 066 private final Logger logger; 067 068 @Nullable 069 private Connection connection; 070 @Nonnull 071 private Boolean rollbackOnly; 072 @Nullable 073 private Boolean initialAutoCommit; 074 075 Transaction(@Nonnull DataSource dataSource, 076 @Nonnull TransactionIsolation transactionIsolation) { 077 requireNonNull(dataSource); 078 requireNonNull(transactionIsolation); 079 080 this.id = generateId(); 081 this.dataSource = dataSource; 082 this.transactionIsolation = transactionIsolation; 083 this.connection = null; 084 this.rollbackOnly = false; 085 this.initialAutoCommit = null; 086 this.postTransactionOperations = new CopyOnWriteArrayList(); 087 this.connectionLock = new ReentrantLock(); 088 this.logger = Logger.getLogger(Transaction.class.getName()); 089 } 090 091 @Override 092 @Nonnull 093 public String toString() { 094 return format("%s{id=%s, transactionIsolation=%s, hasConnection=%s, isRollbackOnly=%s}", 095 getClass().getSimpleName(), id(), getTransactionIsolation(), hasConnection(), isRollbackOnly()); 096 } 097 098 /** 099 * Creates a transaction savepoint that can be rolled back to via {@link #rollback(Savepoint)}. 100 * 101 * @return a transaction savepoint 102 */ 103 @Nonnull 104 public Savepoint createSavepoint() { 105 try { 106 return getConnection().setSavepoint(); 107 } catch (SQLException e) { 108 throw new DatabaseException("Unable to create savepoint", e); 109 } 110 } 111 112 /** 113 * Rolls back to the provided transaction savepoint. 114 * 115 * @param savepoint the savepoint to roll back to 116 */ 117 public void rollback(@Nonnull Savepoint savepoint) { 118 requireNonNull(savepoint); 119 120 try { 121 getConnection().rollback(savepoint); 122 } catch (SQLException e) { 123 throw new DatabaseException("Unable to roll back to savepoint", e); 124 } 125 } 126 127 /** 128 * Should this transaction be rolled back upon completion? 129 * <p> 130 * Default value is {@code false}. 131 * 132 * @return {@code true} if this transaction should be rolled back, {@code false} otherwise 133 */ 134 @Nonnull 135 public Boolean isRollbackOnly() { 136 return this.rollbackOnly; 137 } 138 139 /** 140 * Sets whether this transaction should be rolled back upon completion. 141 * 142 * @param rollbackOnly whether to set this transaction to be rollback-only 143 */ 144 public void setRollbackOnly(@Nonnull Boolean rollbackOnly) { 145 requireNonNull(rollbackOnly); 146 this.rollbackOnly = rollbackOnly; 147 } 148 149 /** 150 * Adds an operation to the list of operations to be executed when the transaction completes. 151 * 152 * @param postTransactionOperation the post-transaction operation to add 153 */ 154 public void addPostTransactionOperation(@Nonnull Consumer<TransactionResult> postTransactionOperation) { 155 requireNonNull(postTransactionOperation); 156 this.postTransactionOperations.add(postTransactionOperation); 157 } 158 159 /** 160 * Removes an operation from the list of operations to be executed when the transaction completes. 161 * 162 * @param postTransactionOperation the post-transaction operation to remove 163 * @return {@code true} if the post-transaction operation was removed, {@code false} otherwise 164 */ 165 @Nonnull 166 public Boolean removePostTransactionOperation(@Nonnull Consumer<TransactionResult> postTransactionOperation) { 167 requireNonNull(postTransactionOperation); 168 return this.postTransactionOperations.remove(postTransactionOperation); 169 } 170 171 /** 172 * Gets an unmodifiable list of post-transaction operations. 173 * <p> 174 * To manipulate the list, use {@link #addPostTransactionOperation(Consumer)} and 175 * {@link #removePostTransactionOperation(Consumer)}. 176 * 177 * @return the list of post-transaction operations 178 */ 179 @Nonnull 180 public List<Consumer<TransactionResult>> getPostTransactionOperations() { 181 return Collections.unmodifiableList(this.postTransactionOperations); 182 } 183 184 /** 185 * Get the isolation level for this transaction. 186 * 187 * @return the isolation level 188 */ 189 @Nonnull 190 public TransactionIsolation getTransactionIsolation() { 191 return this.transactionIsolation; 192 } 193 194 @Nonnull 195 Long id() { 196 return this.id; 197 } 198 199 @Nonnull 200 Boolean hasConnection() { 201 getConnectionLock().lock(); 202 203 try { 204 return this.connection != null; 205 } finally { 206 getConnectionLock().unlock(); 207 } 208 } 209 210 void commit() { 211 getConnectionLock().lock(); 212 213 try { 214 if (!hasConnection()) { 215 logger.finer("Transaction has no connection, so nothing to commit"); 216 return; 217 } 218 219 logger.finer("Committing transaction..."); 220 221 try { 222 getConnection().commit(); 223 logger.finer("Transaction committed."); 224 } catch (SQLException e) { 225 throw new DatabaseException("Unable to commit transaction", e); 226 } 227 } finally { 228 getConnectionLock().unlock(); 229 } 230 } 231 232 void rollback() { 233 getConnectionLock().lock(); 234 235 try { 236 if (!hasConnection()) { 237 logger.finer("Transaction has no connection, so nothing to roll back"); 238 return; 239 } 240 241 logger.finer("Rolling back transaction..."); 242 243 try { 244 getConnection().rollback(); 245 logger.finer("Transaction rolled back."); 246 } catch (SQLException e) { 247 throw new DatabaseException("Unable to roll back transaction", e); 248 } 249 } finally { 250 getConnectionLock().unlock(); 251 } 252 } 253 254 /** 255 * The connection associated with this transaction. 256 * <p> 257 * If no connection is associated yet, we ask the {@link DataSource} for one. 258 * 259 * @return The connection associated with this transaction. 260 * @throws DatabaseException if unable to acquire a connection. 261 */ 262 @Nonnull 263 Connection getConnection() { 264 getConnectionLock().lock(); 265 266 try { 267 if (hasConnection()) 268 return this.connection; 269 270 try { 271 this.connection = getDataSource().getConnection(); 272 } catch (SQLException e) { 273 throw new DatabaseException("Unable to acquire database connection", e); 274 } 275 276 // Keep track of the initial setting for autocommit since it might need to get changed from "true" to "false" for 277 // the duration of the transaction and then back to "true" post-transaction. 278 try { 279 this.initialAutoCommit = this.connection.getAutoCommit(); 280 } catch (SQLException e) { 281 throw new DatabaseException("Unable to determine database connection autocommit setting", e); 282 } 283 284 // Immediately flip autocommit to false if needed...if initially true, it will get set back to true by Database at 285 // the end of the transaction 286 if (this.initialAutoCommit) 287 setAutoCommit(false); 288 289 return this.connection; 290 } finally { 291 getConnectionLock().unlock(); 292 } 293 } 294 295 void setAutoCommit(@Nonnull Boolean autoCommit) { 296 requireNonNull(autoCommit); 297 298 try { 299 getConnection().setAutoCommit(autoCommit); 300 } catch (SQLException e) { 301 throw new DatabaseException(format("Unable to set database connection autocommit value to '%s'", autoCommit), e); 302 } 303 } 304 305 @Nonnull 306 Long generateId() { 307 return ID_GENERATOR.incrementAndGet(); 308 } 309 310 @Nonnull 311 Optional<Boolean> getInitialAutoCommit() { 312 return Optional.ofNullable(this.initialAutoCommit); 313 } 314 315 @Nonnull 316 DataSource getDataSource() { 317 return this.dataSource; 318 } 319 320 @Nonnull 321 protected ReentrantLock getConnectionLock() { 322 return this.connectionLock; 323 } 324}