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.persistence.ocfl.impl;
007
008import static java.lang.String.format;
009import static java.util.concurrent.TimeUnit.MILLISECONDS;
010import static org.apache.jena.graph.NodeFactory.createURI;
011import static org.apache.jena.rdf.model.ModelFactory.createDefaultModel;
012
013import java.io.IOException;
014import java.io.InputStream;
015import java.time.Instant;
016import java.util.ArrayList;
017import java.util.Collections;
018import java.util.HashMap;
019import java.util.List;
020import java.util.Map;
021import java.util.TreeMap;
022import java.util.concurrent.ConcurrentHashMap;
023import java.util.concurrent.Phaser;
024import java.util.concurrent.TimeUnit;
025import java.util.concurrent.TimeoutException;
026import java.util.stream.Collectors;
027
028import org.fcrepo.kernel.api.RdfStream;
029import org.fcrepo.kernel.api.Transaction;
030import org.fcrepo.kernel.api.identifiers.FedoraId;
031import org.fcrepo.kernel.api.models.ResourceHeaders;
032import org.fcrepo.kernel.api.operations.ResourceOperation;
033import org.fcrepo.kernel.api.rdf.DefaultRdfStream;
034import org.fcrepo.persistence.api.PersistentStorageSession;
035import org.fcrepo.persistence.api.exceptions.PersistentItemNotFoundException;
036import org.fcrepo.persistence.api.exceptions.PersistentSessionClosedException;
037import org.fcrepo.persistence.api.exceptions.PersistentStorageException;
038import org.fcrepo.persistence.ocfl.api.FedoraOcflMappingNotFoundException;
039import org.fcrepo.persistence.ocfl.api.FedoraToOcflObjectIndex;
040import org.fcrepo.persistence.ocfl.api.Persister;
041import org.fcrepo.storage.ocfl.OcflObjectSession;
042import org.fcrepo.storage.ocfl.OcflObjectSessionFactory;
043import org.fcrepo.storage.ocfl.OcflVersionInfo;
044
045import org.apache.jena.rdf.model.Model;
046import org.apache.jena.riot.RDFDataMgr;
047import org.slf4j.Logger;
048import org.slf4j.LoggerFactory;
049
050import com.github.benmanes.caffeine.cache.Caffeine;
051
052/**
053 * OCFL Persistent Storage class.
054 *
055 * @author whikloj
056 * @since 2019-09-20
057 */
058public class OcflPersistentStorageSession implements PersistentStorageSession {
059
060    private static final Logger LOGGER = LoggerFactory.getLogger(OcflPersistentStorageSession.class);
061
062    private static final long AWAIT_TIMEOUT = 30000L;
063
064    /**
065     * Externally generated Transaction for the session.
066     */
067    private final Transaction transaction;
068
069    private final FedoraToOcflObjectIndex fedoraOcflIndex;
070
071    private final Map<String, OcflObjectSession> sessionMap;
072
073    private final ReindexService reindexSerivce;
074
075    private Map<String, OcflObjectSession> sessionsToRollback;
076
077    private final Phaser phaser = new Phaser();
078
079    private final List<Persister> persisterList = new ArrayList<>();
080
081    private State state = State.COMMIT_NOT_STARTED;
082
083    private final OcflObjectSessionFactory objectSessionFactory;
084
085    private enum State {
086        COMMIT_NOT_STARTED(true),
087        PREPARE_STARTED(false),
088        PREPARED(true),
089        PREPARE_FAILED(true),
090        COMMIT_STARTED(false),
091        COMMITTED(true),
092        COMMIT_FAILED(true),
093        ROLLING_BACK(false),
094        ROLLED_BACK(false),
095        ROLLBACK_FAILED(false);
096
097        final boolean rollbackAllowed;
098
099        State(final boolean rollbackAllowed) {
100            this.rollbackAllowed = rollbackAllowed;
101        }
102
103    }
104
105    /**
106     * Constructor
107     *
108     * @param tx                   the transaction.
109     * @param fedoraOcflIndex      the index
110     * @param objectSessionFactory the session factory
111     */
112    protected OcflPersistentStorageSession(final Transaction tx,
113                                           final FedoraToOcflObjectIndex fedoraOcflIndex,
114                                           final OcflObjectSessionFactory objectSessionFactory,
115                                           final ReindexService reindexService) {
116        this.transaction = tx;
117        this.fedoraOcflIndex = fedoraOcflIndex;
118        this.objectSessionFactory = objectSessionFactory;
119        this.reindexSerivce = reindexService;
120        this.sessionsToRollback = new HashMap<>();
121
122        if (!tx.isReadOnly()) {
123            this.sessionMap = new ConcurrentHashMap<>();
124        } else {
125            // The read-only session is never closed, so it needs to periodically expire object sessions
126            this.sessionMap = Caffeine.newBuilder()
127                    .maximumSize(512)
128                    .expireAfterAccess(10, TimeUnit.MINUTES)
129                    .<String, OcflObjectSession>build()
130                    .asMap();
131        }
132
133        //load the persister list if empty
134        persisterList.add(new CreateRdfSourcePersister(this.fedoraOcflIndex));
135        persisterList.add(new OverwriteRdfTombstonePersister(this.fedoraOcflIndex));
136        persisterList.add(new UpdateRdfSourcePersister(this.fedoraOcflIndex));
137        persisterList.add(new UpdateNonRdfSourceHeadersPersister(this.fedoraOcflIndex));
138        persisterList.add(new CreateNonRdfSourcePersister(this.fedoraOcflIndex));
139        persisterList.add(new UpdateNonRdfSourcePersister(this.fedoraOcflIndex));
140        persisterList.add(new DeleteResourcePersister(this.fedoraOcflIndex));
141        persisterList.add(new CreateVersionPersister(this.fedoraOcflIndex));
142        persisterList.add(new PurgeResourcePersister(this.fedoraOcflIndex));
143        persisterList.add(new ReindexResourcePersister(this.reindexSerivce));
144
145    }
146
147    @Override
148    public String getId() {
149        return this.transaction.getId();
150    }
151
152    @Override
153    public void persist(final ResourceOperation operation) throws PersistentStorageException {
154        actionNeedsWrite();
155        ensureCommitNotStarted();
156
157        try {
158            phaser.register();
159
160            //resolve the persister based on the operation
161            final var persister = persisterList.stream().filter(p -> p.handle(operation)).findFirst().orElse(null);
162
163            if (persister == null) {
164                throw new UnsupportedOperationException(format("The %s is not yet supported", operation.getClass()));
165            }
166
167            //perform the operation
168            persister.persist(this, operation);
169
170        } finally {
171            phaser.arriveAndDeregister();
172        }
173
174    }
175
176    private void ensureCommitNotStarted() throws PersistentSessionClosedException {
177        if (!state.equals(State.COMMIT_NOT_STARTED)) {
178            throw new PersistentSessionClosedException(
179                    String.format("Storage session %s is already closed", transaction));
180        }
181    }
182
183    private void ensurePrepared() throws PersistentSessionClosedException {
184        if (!state.equals(State.PREPARED)) {
185            throw new PersistentStorageException(
186                    String.format("Storage session %s cannot be committed because it is not in the correct state: %s",
187                            transaction, state));
188        }
189    }
190
191    OcflObjectSession findOrCreateSession(final String ocflId) {
192        return this.sessionMap.computeIfAbsent(ocflId, key -> {
193            return new FcrepoOcflObjectSessionWrapper(this.objectSessionFactory.newSession(key));
194        });
195    }
196
197    @Override
198    public ResourceHeaders getHeaders(final FedoraId identifier, final Instant version)
199            throws PersistentStorageException {
200        ensureCommitNotStarted();
201
202        final FedoraOcflMapping mapping = getFedoraOcflMapping(identifier);
203        final OcflObjectSession objSession = findOrCreateSession(mapping.getOcflObjectId());
204
205        final var versionId = resolveVersionNumber(objSession, identifier, version);
206        final var headers = objSession.readHeaders(identifier.getResourceId(), versionId);
207
208        return new ResourceHeadersAdapter(headers).asKernelHeaders();
209    }
210
211    private FedoraOcflMapping getFedoraOcflMapping(final FedoraId identifier)
212            throws PersistentStorageException {
213        try {
214            return fedoraOcflIndex.getMapping(transaction, identifier);
215        } catch (final FedoraOcflMappingNotFoundException e) {
216            throw new PersistentItemNotFoundException(String.format("Resource %s not found",
217                    identifier.getFullIdPath()), e);
218        }
219    }
220
221    @Override
222    public RdfStream getTriples(final FedoraId identifier, final Instant version)
223            throws PersistentStorageException {
224        ensureCommitNotStarted();
225
226        LOGGER.debug("Getting triples for {} at {}", identifier, version);
227
228        try (final InputStream is = getBinaryContent(identifier, version)) {
229            final Model model = createDefaultModel();
230            RDFDataMgr.read(model, is, OcflPersistentStorageUtils.getRdfFormat().getLang());
231            final FedoraId topic = resolveTopic(identifier);
232            return DefaultRdfStream.fromModel(createURI(topic.getFullId()), model);
233        } catch (final IOException ex) {
234            throw new PersistentStorageException(format("unable to read %s ;  version = %s", identifier, version), ex);
235        }
236    }
237
238    @Override
239    public List<Instant> listVersions(final FedoraId fedoraIdentifier)
240            throws PersistentStorageException {
241        final var mapping = getFedoraOcflMapping(fedoraIdentifier);
242        final var objSession = findOrCreateSession(mapping.getOcflObjectId());
243
244        return objSession.listVersions(fedoraIdentifier.getResourceId()).stream()
245                .map(OcflVersionInfo::getCreated)
246                .collect(Collectors.toList());
247    }
248
249    @Override
250    public InputStream getBinaryContent(final FedoraId identifier, final Instant version)
251            throws PersistentStorageException {
252        ensureCommitNotStarted();
253
254        final var mapping = getFedoraOcflMapping(identifier);
255        final var objSession = findOrCreateSession(mapping.getOcflObjectId());
256
257        final var versionNumber = resolveVersionNumber(objSession, identifier, version);
258
259        return objSession.readContent(identifier.getResourceId(), versionNumber)
260                .getContentStream()
261                .orElseThrow(() -> new PersistentItemNotFoundException("No binary content found for resource "
262                        + identifier.getFullId()));
263    }
264
265    @Override
266    public InputStream getBinaryRange(final FedoraId identifier, final Instant version,
267                                      final long start, final long end) throws PersistentStorageException {
268        ensureCommitNotStarted();
269
270        final var mapping = getFedoraOcflMapping(identifier);
271        final var objSession = findOrCreateSession(mapping.getOcflObjectId());
272
273        final var versionNumber = resolveVersionNumber(objSession, identifier, version);
274
275        return objSession.readRange(identifier.getResourceId(), versionNumber, start, end)
276                .getContentStream()
277                .orElseThrow(() -> new PersistentItemNotFoundException("No binary content found for resource "
278                        + identifier.getFullId()));
279    }
280
281    @Override
282    public synchronized void prepare() {
283        ensureCommitNotStarted();
284        if (isReadOnly()) {
285            // No changes to commit.
286            return;
287        }
288
289        this.state = State.PREPARE_STARTED;
290        LOGGER.debug("Starting storage session {} prepare for commit", transaction);
291
292        if (this.phaser.getRegisteredParties() > 0) {
293            this.phaser.awaitAdvance(0);
294        }
295
296        LOGGER.trace("All persisters are complete in session {}", transaction);
297
298        try {
299            fedoraOcflIndex.commit(transaction);
300            state = State.PREPARED;
301        } catch (final RuntimeException e) {
302            state = State.PREPARE_FAILED;
303            throw new PersistentStorageException(String.format("Failed to prepare storage session <%s> for commit",
304                    transaction), e);
305        }
306    }
307
308    @Override
309    public synchronized void commit() throws PersistentStorageException {
310        ensurePrepared();
311        if (isReadOnly()) {
312            // No changes to commit.
313            return;
314        }
315
316        this.state = State.COMMIT_STARTED;
317        LOGGER.debug("Starting storage session {} commit", transaction);
318
319        // order map for testing
320        final var sessions = new TreeMap<>(sessionMap);
321        commitObjectSessions(sessions);
322
323        LOGGER.debug("Committed storage session {}", transaction);
324    }
325
326    private void commitObjectSessions(final Map<String, OcflObjectSession> sessions)
327            throws PersistentStorageException {
328        this.sessionsToRollback = new HashMap<>(sessionMap.size());
329
330        for (final var entry : sessions.entrySet()) {
331            final var id = entry.getKey();
332            final var session = entry.getValue();
333            try {
334                session.commit();
335                sessionsToRollback.put(id, session);
336            } catch (final Exception e) {
337                this.state = State.COMMIT_FAILED;
338                throw new PersistentStorageException(String.format("Failed to commit object <%s> in session <%s>",
339                        id, transaction), e);
340            }
341        }
342
343        state = State.COMMITTED;
344    }
345
346    @Override
347    public void rollback() throws PersistentStorageException {
348        if (isReadOnly()) {
349            // No changes to rollback
350            return;
351        }
352
353        if (!state.rollbackAllowed) {
354            throw new PersistentStorageException("This session cannot be rolled back in this state: " + state);
355        }
356
357        final boolean commitWasStarted = this.state != State.COMMIT_NOT_STARTED;
358
359        this.state = State.ROLLING_BACK;
360        LOGGER.debug("Rolling back storage session {}", transaction);
361
362        if (!commitWasStarted) {
363            //if the commit had not been started at the time this method was invoked
364            //we must ensure that all persist operations are complete before we close any
365            //ocfl object sessions. If the commit had been started then this synchronization step
366            //will have already occurred and is thus unnecessary.
367            if (this.phaser.getRegisteredParties() > 0) {
368                try {
369                    this.phaser.awaitAdvanceInterruptibly(0, AWAIT_TIMEOUT, MILLISECONDS);
370                } catch (final InterruptedException | TimeoutException e) {
371                    throw new PersistentStorageException(
372                            "Waiting for operations to complete took too long, rollback failed");
373                }
374            }
375        }
376
377        try {
378            closeUncommittedSessions();
379
380            if (commitWasStarted) {
381                rollbackCommittedSessions();
382            }
383        } finally {
384            // Always roll back the index changes associated with the transaction
385            try {
386                fedoraOcflIndex.rollback(transaction);
387            } catch (final Exception e) {
388                LOGGER.error("Failed to rollback OCFL index updates in transaction {}", transaction, e);
389            }
390        }
391
392        this.state = State.ROLLED_BACK;
393        LOGGER.trace("Successfully rolled back storage session {}", transaction);
394    }
395
396    /**
397     * Resolve an instant to a version
398     *
399     * @param objSession session
400     * @param fedoraId the FedoraId of the resource
401     * @param version version time
402     * @return name of version
403     * @throws PersistentStorageException thrown if version not found
404     */
405    private String resolveVersionNumber(final OcflObjectSession objSession,
406                                       final FedoraId fedoraId,
407                                       final Instant version)
408            throws PersistentStorageException {
409        if (version != null) {
410            final var versions = objSession.listVersions(fedoraId.getResourceId());
411            // reverse order so that the most recent version is matched first
412            Collections.reverse(versions);
413            return versions.stream()
414                    .filter(vd -> vd.getCreated().equals(version))
415                    .map(OcflVersionInfo::getVersionNumber)
416                    .findFirst()
417                    .orElseThrow(() -> {
418                        return new PersistentItemNotFoundException(format(
419                                "There is no version in %s with a created date matching %s",
420                                fedoraId, version));
421                    });
422        }
423
424        return null;
425    }
426
427    private void closeUncommittedSessions() {
428        this.sessionMap.entrySet().stream()
429                .filter(entry -> !sessionsToRollback.containsKey(entry.getKey()))
430                .map(Map.Entry::getValue)
431                .forEach(OcflObjectSession::abort);
432    }
433
434    private void rollbackCommittedSessions() throws PersistentStorageException {
435        final List<String> rollbackFailures = new ArrayList<>(this.sessionsToRollback.size());
436
437        for (final var entry : this.sessionsToRollback.entrySet()) {
438            final var id = entry.getKey();
439            final var session = entry.getValue();
440
441            try {
442                session.rollback();
443            } catch (final Exception e) {
444                rollbackFailures.add(String.format("Failed to rollback object <%s> in session <%s>: %s",
445                        id, session.sessionId(), e.getMessage()));
446            }
447        }
448
449        //throw an exception if any sessions could not be rolled back.
450        if (rollbackFailures.size() > 0) {
451            state = State.ROLLBACK_FAILED;
452            final StringBuilder builder = new StringBuilder()
453                    .append("Unable to rollback storage session ")
454                    .append(transaction)
455                    .append(" completely due to the following errors: \n");
456
457            for (final String failures : rollbackFailures) {
458                builder.append("\t").append(failures).append("\n");
459            }
460
461            throw new PersistentStorageException(builder.toString());
462        }
463    }
464
465    /**
466     * Check if we are in a read-only session.
467     *
468     * @return whether we are read-only (ie. no transaction).
469     */
470    private boolean isReadOnly() {
471        return this.transaction.isReadOnly();
472    }
473
474    /**
475     * Utility to throw exception if trying to perform write operation on read-only session.
476     */
477    private void actionNeedsWrite() throws PersistentStorageException {
478        if (isReadOnly()) {
479            throw new PersistentStorageException("Session is read-only");
480        }
481    }
482
483    /**
484     * Returns the RDF topic to be returned for a given resource identifier
485     * For example:  passing info:fedora/resource1/fcr:metadata would return
486     *  info:fedora/resource1 since  info:fedora/resource1 would be the expected
487     *  topic.
488     * @param fedoraIdentifier The fedora identifier
489     * @return The resolved topic
490     */
491    private FedoraId resolveTopic(final FedoraId fedoraIdentifier) {
492        if (fedoraIdentifier.isDescription()) {
493            return fedoraIdentifier.asBaseId();
494        } else {
495            return fedoraIdentifier;
496        }
497    }
498
499    @Override
500    public String toString() {
501        return "OcflPersistentStorageSession{" +
502                "sessionId='" + transaction + '\'' +
503                ", state=" + state +
504                '}';
505    }
506
507}