001/* 002 * Licensed to DuraSpace under one or more contributor license agreements. 003 * See the NOTICE file distributed with this work for additional information 004 * regarding copyright ownership. 005 * 006 * DuraSpace licenses this file to you under the Apache License, 007 * Version 2.0 (the "License"); you may not use this file except in 008 * compliance with the License. You may obtain a copy of the License at 009 * 010 * http://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, software 013 * distributed under the License is distributed on an "AS IS" BASIS, 014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 015 * See the License for the specific language governing permissions and 016 * limitations under the License. 017 */ 018package org.fcrepo.kernel.impl; 019 020import java.time.Duration; 021import java.time.Instant; 022import java.time.temporal.ChronoUnit; 023 024import org.fcrepo.common.lang.CheckedRunnable; 025import org.fcrepo.kernel.api.ContainmentIndex; 026import org.fcrepo.kernel.api.Transaction; 027import org.fcrepo.kernel.api.exception.RepositoryRuntimeException; 028import org.fcrepo.kernel.api.exception.TransactionClosedException; 029import org.fcrepo.kernel.api.identifiers.FedoraId; 030import org.fcrepo.kernel.api.lock.ResourceLockManager; 031import org.fcrepo.kernel.api.observer.EventAccumulator; 032import org.fcrepo.kernel.api.services.MembershipService; 033import org.fcrepo.kernel.api.services.ReferenceService; 034import org.fcrepo.persistence.api.PersistentStorageSession; 035 036import org.slf4j.Logger; 037import org.slf4j.LoggerFactory; 038import org.springframework.dao.DeadlockLoserDataAccessException; 039import org.springframework.transaction.support.TransactionTemplate; 040 041import net.jodah.failsafe.Failsafe; 042import net.jodah.failsafe.RetryPolicy; 043 044/** 045 * The Fedora Transaction implementation 046 * 047 * @author mohideen 048 */ 049public class TransactionImpl implements Transaction { 050 051 private static final Logger log = LoggerFactory.getLogger(TransactionImpl.class); 052 053 private static final RetryPolicy<Object> DB_RETRY = new RetryPolicy<>() 054 .handleIf(e -> { 055 return e instanceof DeadlockLoserDataAccessException 056 || (e.getCause() != null && e.getCause() instanceof DeadlockLoserDataAccessException); 057 }) 058 .withBackoff(50, 1000, ChronoUnit.MILLIS, 1.5) 059 .withJitter(0.1) 060 .withMaxRetries(5); 061 062 private final String id; 063 064 private final TransactionManagerImpl txManager; 065 066 private boolean shortLived = true; 067 068 private Instant expiration; 069 070 private boolean expired = false; 071 072 private boolean rolledback = false; 073 074 private boolean committed = false; 075 076 private String baseUri; 077 078 private String userAgent; 079 080 private Duration sessionTimeout; 081 082 protected TransactionImpl(final String id, 083 final TransactionManagerImpl txManager, 084 final Duration sessionTimeout) { 085 if (id == null || id.isEmpty()) { 086 throw new IllegalArgumentException("Transaction id should not be empty!"); 087 } 088 this.id = id; 089 this.txManager = txManager; 090 this.sessionTimeout = sessionTimeout; 091 this.expiration = Instant.now().plus(sessionTimeout); 092 } 093 094 @Override 095 public synchronized void commit() { 096 failIfExpired(); 097 failIfRolledback(); 098 if (this.committed) { 099 return; 100 } 101 try { 102 log.debug("Committing transaction {}", id); 103 // MySQL can deadlock when update db records and it must be retried. Unfortunately, the entire transaction 104 // must be retried because something marks the transaction for rollback when the exception is thrown 105 // regardless if you then retry at the query level. 106 Failsafe.with(DB_RETRY).run(() -> { 107 // Cannot use transactional annotations because this class is not managed by spring 108 getTransactionTemplate().executeWithoutResult(status -> { 109 this.getContainmentIndex().commitTransaction(id); 110 this.getReferenceService().commitTransaction(id); 111 this.getMembershipService().commitTransaction(id); 112 this.getPersistentSession().prepare(); 113 // The storage session must be committed last because mutable head changes cannot be rolled back. 114 // The db transaction will remain open until all changes have been written to OCFL. If the changes 115 // are large, or are going to S3, this could take some time. In which case, it is possible the 116 // db's connection timeout may need to be adjusted so that the connection is not closed while 117 // waiting for the OCFL changes to be committed. 118 this.getPersistentSession().commit(); 119 }); 120 }); 121 this.getEventAccumulator().emitEvents(id, baseUri, userAgent); 122 this.committed = true; 123 releaseLocks(); 124 } catch (final Exception ex) { 125 log.error("Failed to commit transaction: {}", id, ex); 126 127 // Rollback on commit failure 128 rollback(); 129 throw new RepositoryRuntimeException("Failed to commit transaction " + id, ex); 130 } 131 } 132 133 @Override 134 public synchronized boolean isCommitted() { 135 return committed; 136 } 137 138 @Override 139 public synchronized void rollback() { 140 failIfCommitted(); 141 if (this.rolledback) { 142 return; 143 } 144 log.info("Rolling back transaction {}", id); 145 this.rolledback = true; 146 147 execQuietly("Failed to rollback storage in transaction " + id, () -> { 148 this.getPersistentSession().rollback(); 149 }); 150 execQuietly("Failed to rollback index in transaction " + id, () -> { 151 this.getContainmentIndex().rollbackTransaction(id); 152 }); 153 execQuietly("Failed to rollback reference index in transaction " + id, () -> { 154 this.getReferenceService().rollbackTransaction(id); 155 }); 156 execQuietly("Failed to rollback membership index in transaction " + id, () -> { 157 this.getMembershipService().rollbackTransaction(id); 158 }); 159 execQuietly("Failed to rollback events in transaction " + id, () -> { 160 this.getEventAccumulator().clearEvents(id); 161 }); 162 163 releaseLocks(); 164 } 165 166 @Override 167 public synchronized boolean isRolledBack() { 168 return rolledback; 169 } 170 171 @Override 172 public String getId() { 173 return id; 174 } 175 176 @Override 177 public void setShortLived(final boolean shortLived) { 178 this.shortLived = shortLived; 179 } 180 181 @Override 182 public boolean isShortLived() { 183 return this.shortLived; 184 } 185 186 @Override 187 public synchronized void expire() { 188 this.expiration = Instant.now(); 189 this.expired = true; 190 } 191 192 @Override 193 public boolean hasExpired() { 194 if (this.expired) { 195 return true; 196 } 197 this.expired = this.expiration.isBefore(Instant.now()); 198 return this.expired; 199 } 200 201 @Override 202 public synchronized Instant updateExpiry(final Duration amountToAdd) { 203 failIfExpired(); 204 failIfCommitted(); 205 failIfRolledback(); 206 this.expiration = this.expiration.plus(amountToAdd); 207 return this.expiration; 208 } 209 210 @Override 211 public Instant getExpires() { 212 return this.expiration; 213 } 214 215 @Override 216 public void commitIfShortLived() { 217 if (this.isShortLived()) { 218 this.commit(); 219 } 220 } 221 222 @Override 223 public void refresh() { 224 updateExpiry(sessionTimeout); 225 } 226 227 @Override 228 public void lockResource(final FedoraId resourceId) { 229 getResourceLockManger().acquire(getId(), resourceId); 230 } 231 232 @Override 233 public void releaseResourceLocksIfShortLived() { 234 if (isShortLived()) { 235 releaseLocks(); 236 } 237 } 238 239 @Override 240 public void setBaseUri(final String baseUri) { 241 this.baseUri = baseUri; 242 } 243 244 @Override 245 public void setUserAgent(final String userAgent) { 246 this.userAgent = userAgent; 247 } 248 249 private PersistentStorageSession getPersistentSession() { 250 return this.txManager.getPersistentStorageSessionManager().getSession(this.id); 251 } 252 253 private void failIfExpired() { 254 if (hasExpired()) { 255 throw new TransactionClosedException("Transaction with transactionId: " + id + " expired!"); 256 } 257 } 258 259 private void failIfCommitted() { 260 if (this.committed) { 261 throw new TransactionClosedException("Transaction with transactionId: " + id + " is already committed!"); 262 } 263 } 264 265 private void failIfRolledback() { 266 if (this.rolledback) { 267 throw new TransactionClosedException("Transaction with transactionId: " + id + " is already rolledback!"); 268 } 269 } 270 271 private void releaseLocks() { 272 execQuietly("Failed to release resource locks cleanly. You may need to restart Fedora.", () -> { 273 getResourceLockManger().releaseAll(getId()); 274 }); 275 } 276 277 /** 278 * Executes the closure, capturing all exceptions, and logging them as errors. 279 * 280 * @param failureMessage what to print if the closure fails 281 * @param callable closure to execute 282 */ 283 private void execQuietly(final String failureMessage, final CheckedRunnable callable) { 284 try { 285 callable.run(); 286 } catch (final Exception e) { 287 log.error(failureMessage, e); 288 } 289 } 290 291 private ContainmentIndex getContainmentIndex() { 292 return this.txManager.getContainmentIndex(); 293 } 294 295 private EventAccumulator getEventAccumulator() { 296 return this.txManager.getEventAccumulator(); 297 } 298 299 private ReferenceService getReferenceService() { 300 return this.txManager.getReferenceService(); 301 } 302 303 private MembershipService getMembershipService() { 304 return this.txManager.getMembershipService(); 305 } 306 307 private TransactionTemplate getTransactionTemplate() { 308 return this.txManager.getTransactionTemplate(); 309 } 310 311 private ResourceLockManager getResourceLockManger() { 312 return this.txManager.getResourceLockManager(); 313 } 314 315}