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