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}