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