001/* 002 * The contents of this file are subject to the license and copyright 003 * detailed in the LICENSE and NOTICE files at the root of the source 004 * tree. 005 */ 006package org.fcrepo.kernel.impl; 007 008import java.time.Duration; 009import java.time.Instant; 010import java.util.concurrent.Phaser; 011 012import org.fcrepo.common.db.DbTransactionExecutor; 013import org.fcrepo.common.lang.CheckedRunnable; 014import org.fcrepo.kernel.api.ContainmentIndex; 015import org.fcrepo.kernel.api.Transaction; 016import org.fcrepo.kernel.api.TransactionState; 017import org.fcrepo.kernel.api.cache.UserTypesCache; 018import org.fcrepo.kernel.api.exception.RepositoryRuntimeException; 019import org.fcrepo.kernel.api.exception.TransactionClosedException; 020import org.fcrepo.kernel.api.exception.TransactionRuntimeException; 021import org.fcrepo.kernel.api.identifiers.FedoraId; 022import org.fcrepo.kernel.api.lock.ResourceLockManager; 023import org.fcrepo.kernel.api.observer.EventAccumulator; 024import org.fcrepo.kernel.api.services.MembershipService; 025import org.fcrepo.kernel.api.services.ReferenceService; 026import org.fcrepo.persistence.api.PersistentStorageSession; 027import org.fcrepo.search.api.SearchIndex; 028 029import org.slf4j.Logger; 030import org.slf4j.LoggerFactory; 031 032/** 033 * The Fedora Transaction implementation 034 * 035 * @author mohideen 036 */ 037public class TransactionImpl implements Transaction { 038 039 private static final Logger log = LoggerFactory.getLogger(TransactionImpl.class); 040 041 private final String id; 042 043 private final TransactionManagerImpl txManager; 044 045 private TransactionState state; 046 047 private boolean shortLived = true; 048 049 private Instant expiration; 050 051 private boolean expired = false; 052 053 private String baseUri; 054 055 private String userAgent; 056 057 private final Duration sessionTimeout; 058 059 private final Phaser operationPhaser; 060 061 protected TransactionImpl(final String id, 062 final TransactionManagerImpl txManager, 063 final Duration sessionTimeout) { 064 if (id == null || id.isEmpty()) { 065 throw new IllegalArgumentException("Transaction id should not be empty!"); 066 } 067 this.id = id; 068 this.txManager = txManager; 069 this.sessionTimeout = sessionTimeout; 070 this.expiration = Instant.now().plus(sessionTimeout); 071 this.state = TransactionState.OPEN; 072 this.operationPhaser = new Phaser(); 073 } 074 075 @Override 076 public synchronized void commit() { 077 if (state == TransactionState.COMMITTED) { 078 return; 079 } 080 failIfNotOpen(); 081 failIfExpired(); 082 083 updateState(TransactionState.COMMITTING); 084 085 log.debug("Waiting for operations in transaction {} to complete before committing", id); 086 087 operationPhaser.register(); 088 operationPhaser.awaitAdvance(operationPhaser.arriveAndDeregister()); 089 090 log.debug("Committing transaction {}", id); 091 092 try { 093 if (isShortLived()) { 094 doCommitShortLived(); 095 } else { 096 doCommitLongRunning(); 097 } 098 099 updateState(TransactionState.COMMITTED); 100 this.getEventAccumulator().emitEvents(this, baseUri, userAgent); 101 releaseLocks(); 102 log.debug("Committed transaction {}", id); 103 } catch (final Exception ex) { 104 log.error("Failed to commit transaction: {}", id, ex); 105 106 // Rollback on commit failure 107 log.info("Rolling back transaction {}", id); 108 rollback(); 109 throw new RepositoryRuntimeException("Failed to commit transaction " + id, ex); 110 } 111 } 112 113 @Override 114 public boolean isCommitted() { 115 return state == TransactionState.COMMITTED; 116 } 117 118 @Override 119 public synchronized void rollback() { 120 if (state == TransactionState.ROLLEDBACK || state == TransactionState.ROLLINGBACK) { 121 return; 122 } 123 124 failIfCommitted(); 125 126 updateState(TransactionState.ROLLINGBACK); 127 128 log.debug("Waiting for operations in transaction {} to complete before rolling back", id); 129 130 operationPhaser.register(); 131 operationPhaser.awaitAdvance(operationPhaser.arriveAndDeregister()); 132 133 execQuietly("Failed to rollback storage in transaction " + id, () -> { 134 this.getPersistentSession().rollback(); 135 }); 136 execQuietly("Failed to rollback index in transaction " + id, () -> { 137 this.getContainmentIndex().rollbackTransaction(this); 138 }); 139 execQuietly("Failed to rollback reference index in transaction " + id, () -> { 140 this.getReferenceService().rollbackTransaction(this); 141 }); 142 execQuietly("Failed to rollback membership index in transaction " + id, () -> { 143 this.getMembershipService().rollbackTransaction(this); 144 }); 145 execQuietly("Failed to rollback search index in transaction " + id, () -> { 146 this.getSearchIndex().rollbackTransaction(this); 147 }); 148 149 execQuietly("Failed to rollback events in transaction " + id, () -> { 150 this.getEventAccumulator().clearEvents(this); 151 }); 152 153 execQuietly("Failed to clear user rdf types cache in transaction " + id, () -> { 154 this.getUserTypesCache().dropSessionCache(id); 155 }); 156 157 updateState(TransactionState.ROLLEDBACK); 158 159 releaseLocks(); 160 } 161 162 @Override 163 public void doInTx(final Runnable runnable) { 164 operationPhaser.register(); 165 166 try { 167 failIfNotOpen(); 168 failIfExpired(); 169 170 runnable.run(); 171 } finally { 172 operationPhaser.arriveAndDeregister(); 173 } 174 } 175 176 @Override 177 public synchronized void fail() { 178 if (state != TransactionState.OPEN) { 179 log.error("Transaction {} is in state {} and may not be marked as FAILED", id, state); 180 } else { 181 updateState(TransactionState.FAILED); 182 } 183 } 184 185 @Override 186 public boolean isRolledBack() { 187 return state == TransactionState.ROLLEDBACK; 188 } 189 190 @Override 191 public String getId() { 192 return id; 193 } 194 195 @Override 196 public void setShortLived(final boolean shortLived) { 197 this.shortLived = shortLived; 198 } 199 200 @Override 201 public boolean isShortLived() { 202 return this.shortLived; 203 } 204 205 @Override 206 public boolean isOpenLongRunning() { 207 return !this.isShortLived() && !hasExpired() 208 && !(state == TransactionState.COMMITTED 209 || state == TransactionState.ROLLEDBACK 210 || state == TransactionState.FAILED); 211 } 212 213 @Override 214 public boolean isOpen() { 215 return state == TransactionState.OPEN && !hasExpired(); 216 } 217 218 @Override 219 public void ensureCommitting() { 220 if (state != TransactionState.COMMITTING) { 221 throw new TransactionRuntimeException( 222 String.format("Transaction %s must be in state COMMITTING, but was %s", id, state)); 223 } 224 } 225 226 @Override 227 public boolean isReadOnly() { 228 return false; 229 } 230 231 @Override 232 public void expire() { 233 this.expiration = Instant.now(); 234 this.expired = true; 235 } 236 237 @Override 238 public boolean hasExpired() { 239 if (this.expired) { 240 return true; 241 } 242 this.expired = this.expiration.isBefore(Instant.now()); 243 return this.expired; 244 } 245 246 @Override 247 public synchronized Instant updateExpiry(final Duration amountToAdd) { 248 failIfExpired(); 249 failIfCommitted(); 250 failIfNotOpen(); 251 this.expiration = this.expiration.plus(amountToAdd); 252 return this.expiration; 253 } 254 255 @Override 256 public Instant getExpires() { 257 return this.expiration; 258 } 259 260 @Override 261 public void commitIfShortLived() { 262 if (this.isShortLived()) { 263 this.commit(); 264 } 265 } 266 267 @Override 268 public void refresh() { 269 updateExpiry(sessionTimeout); 270 } 271 272 @Override 273 public void lockResource(final FedoraId resourceId) { 274 getResourceLockManger().acquire(getId(), resourceId); 275 } 276 277 @Override 278 public void releaseResourceLocksIfShortLived() { 279 if (isShortLived()) { 280 releaseLocks(); 281 } 282 } 283 284 @Override 285 public void setBaseUri(final String baseUri) { 286 this.baseUri = baseUri; 287 } 288 289 @Override 290 public void setUserAgent(final String userAgent) { 291 this.userAgent = userAgent; 292 } 293 294 private void doCommitShortLived() { 295 // short-lived txs do not write to tx tables and do not need to commit db indexes. 296 this.getPersistentSession().prepare(); 297 this.getPersistentSession().commit(); 298 this.getUserTypesCache().mergeSessionCache(id); 299 } 300 301 private void doCommitLongRunning() { 302 getDbTransactionExecutor().doInTxWithRetry(() -> { 303 this.getContainmentIndex().commitTransaction(this); 304 this.getReferenceService().commitTransaction(this); 305 this.getMembershipService().commitTransaction(this); 306 this.getSearchIndex().commitTransaction(this); 307 this.getPersistentSession().prepare(); 308 // The storage session must be committed last because mutable head changes cannot be rolled back. 309 // The db transaction will remain open until all changes have been written to OCFL. If the changes 310 // are large, or are going to S3, this could take some time. In which case, it is possible the 311 // db's connection timeout may need to be adjusted so that the connection is not closed while 312 // waiting for the OCFL changes to be committed. 313 this.getPersistentSession().commit(); 314 this.getUserTypesCache().mergeSessionCache(id); 315 }); 316 } 317 318 private void updateState(final TransactionState newState) { 319 this.state = newState; 320 } 321 322 private PersistentStorageSession getPersistentSession() { 323 return this.txManager.getPersistentStorageSessionManager().getSession(this); 324 } 325 326 private void failIfExpired() { 327 if (hasExpired()) { 328 throw new TransactionClosedException("Transaction " + id + " expired!"); 329 } 330 } 331 332 private void failIfCommitted() { 333 if (state == TransactionState.COMMITTED) { 334 throw new TransactionClosedException( 335 String.format("Transaction %s cannot be transitioned because it is already committed!", id)); 336 } 337 } 338 339 private void failIfNotOpen() { 340 if (state == TransactionState.FAILED) { 341 throw new TransactionRuntimeException( 342 String.format("Transaction %s cannot be committed because it is in a failed state!", id)); 343 } else if (state != TransactionState.OPEN) { 344 throw new TransactionClosedException( 345 String.format("Transaction %s cannot be committed because it is in state %s!", id, state)); 346 } 347 } 348 349 private void releaseLocks() { 350 execQuietly("Failed to release resource locks cleanly. You may need to restart Fedora.", () -> { 351 getResourceLockManger().releaseAll(getId()); 352 }); 353 } 354 355 /** 356 * Executes the closure, capturing all exceptions, and logging them as errors. 357 * 358 * @param failureMessage what to print if the closure fails 359 * @param callable closure to execute 360 */ 361 private void execQuietly(final String failureMessage, final CheckedRunnable callable) { 362 try { 363 callable.run(); 364 } catch (final Exception e) { 365 log.error(failureMessage, e); 366 } 367 } 368 369 private ContainmentIndex getContainmentIndex() { 370 return this.txManager.getContainmentIndex(); 371 } 372 373 private EventAccumulator getEventAccumulator() { 374 return this.txManager.getEventAccumulator(); 375 } 376 377 private ReferenceService getReferenceService() { 378 return this.txManager.getReferenceService(); 379 } 380 381 private MembershipService getMembershipService() { 382 return this.txManager.getMembershipService(); 383 } 384 385 private SearchIndex getSearchIndex() { 386 return this.txManager.getSearchIndex(); 387 } 388 389 private DbTransactionExecutor getDbTransactionExecutor() { 390 return this.txManager.getDbTransactionExecutor(); 391 } 392 393 private ResourceLockManager getResourceLockManger() { 394 return this.txManager.getResourceLockManager(); 395 } 396 397 private UserTypesCache getUserTypesCache() { 398 return this.txManager.getUserTypesCache(); 399 } 400 401 @Override 402 public String toString() { 403 return id; 404 } 405}