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}