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