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 synchronized void prepare() {
267        ensureCommitNotStarted();
268        if (isReadOnly()) {
269            // No changes to commit.
270            return;
271        }
272
273        this.state = State.PREPARE_STARTED;
274        LOGGER.debug("Starting storage session {} prepare for commit", transaction);
275
276        if (this.phaser.getRegisteredParties() > 0) {
277            this.phaser.awaitAdvance(0);
278        }
279
280        LOGGER.trace("All persisters are complete in session {}", transaction);
281
282        try {
283            fedoraOcflIndex.commit(transaction);
284            state = State.PREPARED;
285        } catch (final RuntimeException e) {
286            state = State.PREPARE_FAILED;
287            throw new PersistentStorageException(String.format("Failed to prepare storage session <%s> for commit",
288                    transaction), e);
289        }
290    }
291
292    @Override
293    public synchronized void commit() throws PersistentStorageException {
294        ensurePrepared();
295        if (isReadOnly()) {
296            // No changes to commit.
297            return;
298        }
299
300        this.state = State.COMMIT_STARTED;
301        LOGGER.debug("Starting storage session {} commit", transaction);
302
303        // order map for testing
304        final var sessions = new TreeMap<>(sessionMap);
305        commitObjectSessions(sessions);
306
307        LOGGER.debug("Committed storage session {}", transaction);
308    }
309
310    private void commitObjectSessions(final Map<String, OcflObjectSession> sessions)
311            throws PersistentStorageException {
312        this.sessionsToRollback = new HashMap<>(sessionMap.size());
313
314        for (final var entry : sessions.entrySet()) {
315            final var id = entry.getKey();
316            final var session = entry.getValue();
317            try {
318                session.commit();
319                sessionsToRollback.put(id, session);
320            } catch (final Exception e) {
321                this.state = State.COMMIT_FAILED;
322                throw new PersistentStorageException(String.format("Failed to commit object <%s> in session <%s>",
323                        id, transaction), e);
324            }
325        }
326
327        state = State.COMMITTED;
328    }
329
330    @Override
331    public void rollback() throws PersistentStorageException {
332        if (isReadOnly()) {
333            // No changes to rollback
334            return;
335        }
336
337        if (!state.rollbackAllowed) {
338            throw new PersistentStorageException("This session cannot be rolled back in this state: " + state);
339        }
340
341        final boolean commitWasStarted = this.state != State.COMMIT_NOT_STARTED;
342
343        this.state = State.ROLLING_BACK;
344        LOGGER.debug("Rolling back storage session {}", transaction);
345
346        if (!commitWasStarted) {
347            //if the commit had not been started at the time this method was invoked
348            //we must ensure that all persist operations are complete before we close any
349            //ocfl object sessions. If the commit had been started then this synchronization step
350            //will have already occurred and is thus unnecessary.
351            if (this.phaser.getRegisteredParties() > 0) {
352                try {
353                    this.phaser.awaitAdvanceInterruptibly(0, AWAIT_TIMEOUT, MILLISECONDS);
354                } catch (final InterruptedException | TimeoutException e) {
355                    throw new PersistentStorageException(
356                            "Waiting for operations to complete took too long, rollback failed");
357                }
358            }
359        }
360
361        closeUncommittedSessions();
362
363        if (commitWasStarted) {
364            rollbackCommittedSessions();
365        }
366
367        this.state = State.ROLLED_BACK;
368        LOGGER.trace("Successfully rolled back storage session {}", transaction);
369    }
370
371    /**
372     * Resolve an instant to a version
373     *
374     * @param objSession session
375     * @param fedoraId the FedoraId of the resource
376     * @param version version time
377     * @return name of version
378     * @throws PersistentStorageException thrown if version not found
379     */
380    private String resolveVersionNumber(final OcflObjectSession objSession,
381                                       final FedoraId fedoraId,
382                                       final Instant version)
383            throws PersistentStorageException {
384        if (version != null) {
385            final var versions = objSession.listVersions(fedoraId.getResourceId());
386            // reverse order so that the most recent version is matched first
387            Collections.reverse(versions);
388            return versions.stream()
389                    .filter(vd -> vd.getCreated().equals(version))
390                    .map(OcflVersionInfo::getVersionNumber)
391                    .findFirst()
392                    .orElseThrow(() -> {
393                        return new PersistentItemNotFoundException(format(
394                                "There is no version in %s with a created date matching %s",
395                                fedoraId, version));
396                    });
397        }
398
399        return null;
400    }
401
402    private void closeUncommittedSessions() {
403        this.sessionMap.entrySet().stream()
404                .filter(entry -> !sessionsToRollback.containsKey(entry.getKey()))
405                .map(Map.Entry::getValue)
406                .forEach(OcflObjectSession::abort);
407    }
408
409    private void rollbackCommittedSessions() throws PersistentStorageException {
410        final List<String> rollbackFailures = new ArrayList<>(this.sessionsToRollback.size());
411
412        for (final var entry : this.sessionsToRollback.entrySet()) {
413            final var id = entry.getKey();
414            final var session = entry.getValue();
415
416            try {
417                session.rollback();
418            } catch (final Exception e) {
419                rollbackFailures.add(String.format("Failed to rollback object <%s> in session <%s>: %s",
420                        id, session.sessionId(), e.getMessage()));
421            }
422        }
423
424        try {
425            fedoraOcflIndex.rollback(transaction);
426        } catch (final Exception e) {
427            rollbackFailures.add(String.format("Failed to rollback OCFL index updates in transaction <%s>: %s",
428                    transaction, e.getMessage()));
429        }
430
431        //throw an exception if any sessions could not be rolled back.
432        if (rollbackFailures.size() > 0) {
433            state = State.ROLLBACK_FAILED;
434            final StringBuilder builder = new StringBuilder()
435                    .append("Unable to rollback storage session ")
436                    .append(transaction)
437                    .append(" completely due to the following errors: \n");
438
439            for (final String failures : rollbackFailures) {
440                builder.append("\t").append(failures).append("\n");
441            }
442
443            throw new PersistentStorageException(builder.toString());
444        }
445    }
446
447    /**
448     * Check if we are in a read-only session.
449     *
450     * @return whether we are read-only (ie. no transaction).
451     */
452    private boolean isReadOnly() {
453        return this.transaction.isReadOnly();
454    }
455
456    /**
457     * Utility to throw exception if trying to perform write operation on read-only session.
458     */
459    private void actionNeedsWrite() throws PersistentStorageException {
460        if (isReadOnly()) {
461            throw new PersistentStorageException("Session is read-only");
462        }
463    }
464
465    /**
466     * Returns the RDF topic to be returned for a given resource identifier
467     * For example:  passing info:fedora/resource1/fcr:metadata would return
468     *  info:fedora/resource1 since  info:fedora/resource1 would be the expected
469     *  topic.
470     * @param fedoraIdentifier The fedora identifier
471     * @return The resolved topic
472     */
473    private FedoraId resolveTopic(final FedoraId fedoraIdentifier) {
474        if (fedoraIdentifier.isDescription()) {
475            return fedoraIdentifier.asBaseId();
476        } else {
477            return fedoraIdentifier;
478        }
479    }
480
481    @Override
482    public String toString() {
483        return "OcflPersistentStorageSession{" +
484                "sessionId='" + transaction + '\'' +
485                ", state=" + state +
486                '}';
487    }
488
489}