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}