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