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}