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}