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}