diff --git a/src/main/scala/de/retest/guistatemachine/api/GuiStateMachineApi.scala b/src/main/scala/de/retest/guistatemachine/api/GuiStateMachineApi.scala index 8a5a96c..c09ef18 100644 --- a/src/main/scala/de/retest/guistatemachine/api/GuiStateMachineApi.scala +++ b/src/main/scala/de/retest/guistatemachine/api/GuiStateMachineApi.scala @@ -2,7 +2,7 @@ package de.retest.guistatemachine.api import java.nio.file.Paths import de.retest.guistatemachine.api.impl.GuiStateMachineApiImpl -import de.retest.guistatemachine.api.neo4j.GuiStateMachineApiNeo4J +import de.retest.guistatemachine.api.neo4j.GuiStateMachineApiNeo4JEmbedded /** * This API allows the creation, modification and deletion of state machines ([[GuiStateMachine]]) which are created @@ -44,7 +44,7 @@ object GuiStateMachineApi { /** * The default directory where all state machines are stored. */ - val StorageDirectory = Paths.get(System.getProperty("user.home"), ".retest", "guistatemachines").toAbsolutePath.toString + val Neo4JEmbeddedStorageDirectory: Path = Paths.get(System.getProperty("user.home"), ".retest", "guistatemachines").toAbsolutePath.toString val default = new GuiStateMachineApiImpl @@ -53,5 +53,5 @@ object GuiStateMachineApi { */ def apply(): GuiStateMachineApi = default - val neo4j = new GuiStateMachineApiNeo4J(StorageDirectory) + val neo4jEmbedded = new GuiStateMachineApiNeo4JEmbedded(Paths.get(Neo4JEmbeddedStorageDirectory)) } diff --git a/src/main/scala/de/retest/guistatemachine/api/example/Example.scala b/src/main/scala/de/retest/guistatemachine/api/example/Example.scala index a5963fd..c447c66 100644 --- a/src/main/scala/de/retest/guistatemachine/api/example/Example.scala +++ b/src/main/scala/de/retest/guistatemachine/api/example/Example.scala @@ -15,10 +15,11 @@ object Example extends App { private val action0 = new NavigateToAction("http://google.com") private val action1 = new NavigateToAction("http://wikipedia.org") - val stateMachine = GuiStateMachineApi.neo4j.getStateMachine("tmp") match { + val api = GuiStateMachineApi.neo4jEmbedded + val stateMachine = api.getStateMachine("tmp") match { case Some(s) => s - case None => GuiStateMachineApi.neo4j.createStateMachine("tmp") + case None => api.createStateMachine("tmp") } println(s"All states before clearing: ${stateMachine.getAllStates.size}") diff --git a/src/main/scala/de/retest/guistatemachine/api/neo4j/GuiStateMachineApiNeo4J.scala b/src/main/scala/de/retest/guistatemachine/api/neo4j/GuiStateMachineApiNeo4J.scala deleted file mode 100644 index f8b1dcb..0000000 --- a/src/main/scala/de/retest/guistatemachine/api/neo4j/GuiStateMachineApiNeo4J.scala +++ /dev/null @@ -1,51 +0,0 @@ -package de.retest.guistatemachine.api.neo4j - -import java.io.File -import java.nio.file.Paths - -import com.typesafe.scalalogging.Logger -import de.retest.guistatemachine.api.{GuiStateMachine, GuiStateMachineApi} -import org.apache.commons.io.FileUtils - -class GuiStateMachineApiNeo4J(directory: String) extends GuiStateMachineApi { - private val logger = Logger[GuiStateMachineApiNeo4J] - - override def createStateMachine(name: String): GuiStateMachine = - if (isDirectory(name)) { - throw new RuntimeException(s"State machine $name does already exist.") - } else { - val uri = getUri(name) - Neo4jSessionFactory.getSessionFactoryEmbedded(uri) - logger.info("Created new graph DB in {}.", uri) - - new GuiStateMachineNeo4J(uri) - } - - override def removeStateMachine(name: String): Boolean = - if (isDirectory(name)) { - val file = getFile(name) - logger.info("Deleting state machine in {}.", file) - FileUtils.deleteDirectory(file) - true - } else { - false - } - - override def getStateMachine(name: String): Option[GuiStateMachine] = - if (isDirectory(name)) { - val uri = getUri(name) - Some(new GuiStateMachineNeo4J(uri)) - } else { - None - } - - override def clear(): Unit = { - logger.info("Deleting all state machines in {}.", directory) - FileUtils.deleteDirectory(getStorageDirectory()) - } - - private def getStorageDirectory(): File = new File(directory) - private def isDirectory(name: String): Boolean = getFile(name).isDirectory - private def getFile(name: String): File = new File(Paths.get(directory, name).toAbsolutePath.toString) - private def getUri(name: String): String = getFile(name).toURI.toString -} diff --git a/src/main/scala/de/retest/guistatemachine/api/neo4j/GuiStateMachineApiNeo4JBolt.scala b/src/main/scala/de/retest/guistatemachine/api/neo4j/GuiStateMachineApiNeo4JBolt.scala new file mode 100644 index 0000000..e380407 --- /dev/null +++ b/src/main/scala/de/retest/guistatemachine/api/neo4j/GuiStateMachineApiNeo4JBolt.scala @@ -0,0 +1,19 @@ +package de.retest.guistatemachine.api.neo4j + +import com.typesafe.scalalogging.Logger +import de.retest.guistatemachine.api.{GuiStateMachine, GuiStateMachineApi} + +class GuiStateMachineApiNeo4JBolt(url: String, port: Int, user: String, password: String) extends GuiStateMachineApi { + private val logger = Logger[GuiStateMachineApiNeo4JBolt] + + override def createStateMachine(name: String): GuiStateMachine = { + val conf = BoltConfig(url, port, user, password) // TODO #19 How to distinguish between databases. + new GuiStateMachineNeo4J(conf, Neo4JSessionFactory.getSessionFactory(conf)) + } + + override def removeStateMachine(name: String): Boolean = false // TODO #19 How to remove a Bolt database? + + override def getStateMachine(name: String): Option[GuiStateMachine] = None // TODO #19 How to get a Bolt database? Some list? + + override def clear(): Unit = {} // TODO #19 How to remove all Bolt databases? +} diff --git a/src/main/scala/de/retest/guistatemachine/api/neo4j/GuiStateMachineApiNeo4JEmbedded.scala b/src/main/scala/de/retest/guistatemachine/api/neo4j/GuiStateMachineApiNeo4JEmbedded.scala new file mode 100644 index 0000000..941a7bd --- /dev/null +++ b/src/main/scala/de/retest/guistatemachine/api/neo4j/GuiStateMachineApiNeo4JEmbedded.scala @@ -0,0 +1,75 @@ +package de.retest.guistatemachine.api.neo4j + +import java.io.File +import java.nio.file.{Path, Paths} + +import com.typesafe.scalalogging.Logger +import de.retest.guistatemachine.api.{GuiStateMachine, GuiStateMachineApi} +import org.apache.commons.io.FileUtils + +/** + * This implementation is only thread-safe but cannot be used by multiple processes on the same storage directory. + * There should be only one single instance of this implementation per storage directory shared in the whole application. + * @param storageDirectory The directory where all subdirectories for embedded graph databases are created. + */ +class GuiStateMachineApiNeo4JEmbedded(storageDirectory: Path) extends GuiStateMachineApi { + private val logger = Logger[GuiStateMachineApiNeo4JEmbedded] + + override def createStateMachine(name: String): GuiStateMachine = synchronized { + if (isDirectory(name)) { + throw new RuntimeException(s"State machine $name does already exist.") + } else { + val path = getPath(name) + logger.info("Created new graph DB in {}.", path) + val conf = EmbeddedConfig(path) + val sessionFactory = Neo4JSessionFactory.getSessionFactory(conf) + new GuiStateMachineNeo4J(conf, sessionFactory) + } + } + + override def removeStateMachine(name: String): Boolean = synchronized { + if (isDirectory(name)) { + val file = getFile(name) + logger.info("Deleting state machine in {}.", file) + FileUtils.deleteDirectory(file) + true + } else { + false + } + } + + override def getStateMachine(name: String): Option[GuiStateMachine] = synchronized { + if (isDirectory(name)) { + val path = getPath(name) + logger.info("Getting graph DB in {}.", path) + val conf = EmbeddedConfig(path) + val sessionFactory = Neo4JSessionFactory.getSessionFactory(conf) + Some(new GuiStateMachineNeo4J(conf, sessionFactory)) + } else { + None + } + } + + override def clear(): Unit = synchronized { + val storageDir = storageDirectory.toFile + if (storageDir.isDirectory) { + storageDir.listFiles().toSeq foreach { file => + Neo4JSessionFactory.removeSessionFactory(EmbeddedConfig(file.toPath)) + } + logger.info("Deleting all state machines in {}.", storageDirectory) + FileUtils.deleteDirectory(storageDir) + } else { + logger.info("Directory {} does not exist.", storageDirectory) + } + } + + /** + * Gets the path of the embedded database with a certain name relative to the storage directory path. + * It needs to add an "embedded" directory since the parent directory will contain the lock and a logs directory. + * @param name The name of the embedded database. + * @return The path of the directory of the embedded database. + */ + private def getPath(name: String): Path = storageDirectory.resolve(Paths.get(name, "embedded")) + private def getFile(name: String): File = getPath(name).toFile + private def isDirectory(name: String): Boolean = getFile(name).isDirectory +} diff --git a/src/main/scala/de/retest/guistatemachine/api/neo4j/GuiStateMachineNeo4J.scala b/src/main/scala/de/retest/guistatemachine/api/neo4j/GuiStateMachineNeo4J.scala index 5c0f7de..a9e34b3 100644 --- a/src/main/scala/de/retest/guistatemachine/api/neo4j/GuiStateMachineNeo4J.scala +++ b/src/main/scala/de/retest/guistatemachine/api/neo4j/GuiStateMachineNeo4J.scala @@ -3,15 +3,15 @@ package de.retest.guistatemachine.api.neo4j import com.typesafe.scalalogging.Logger import de.retest.guistatemachine.api.{GuiStateMachine, State, SutStateIdentifier} import org.neo4j.ogm.cypher.{ComparisonOperator, Filter} -import org.neo4j.ogm.session.Session +import org.neo4j.ogm.session.{Session, SessionFactory} import scala.collection.immutable.HashMap -class GuiStateMachineNeo4J(var uri: String) extends GuiStateMachine { +class GuiStateMachineNeo4J(var config: Neo4JConfig, var sessionFactory: SessionFactory) extends GuiStateMachine { private val logger = Logger[GuiStateMachineNeo4J] override def getState(sutStateIdentifier: SutStateIdentifier): State = { - val result = Neo4jSessionFactory.transaction { session => + val result = Neo4JUtil.transaction { session => getNodeBySutStateIdentifier(session, sutStateIdentifier) match { case None => // Create a new node for the SUT state in the graph database. @@ -22,7 +22,7 @@ class GuiStateMachineNeo4J(var uri: String) extends GuiStateMachine { // Do nothing if the node for the SUT state does already exist. case Some(_) => false } - }(uri) + }(sessionFactory) if (result) { logger.info(s"Created new state from SUT state identifier $sutStateIdentifier.") @@ -32,7 +32,7 @@ class GuiStateMachineNeo4J(var uri: String) extends GuiStateMachine { } override def getAllStates: Map[SutStateIdentifier, State] = - Neo4jSessionFactory.transaction { session => + Neo4JUtil.transaction { session => val allNodes = session.loadAll(classOf[SutStateEntity]) var result = HashMap[SutStateIdentifier, State]() val iterator = allNodes.iterator() @@ -43,17 +43,18 @@ class GuiStateMachineNeo4J(var uri: String) extends GuiStateMachine { result = result + (sutState -> StateNeo4J(sutState, this)) } result - }(uri) + }(sessionFactory) override def clear(): Unit = - Neo4jSessionFactory.transaction { session => + Neo4JUtil.transaction { session => session.purgeDatabase() - }(uri) + }(sessionFactory) override def assignFrom(other: GuiStateMachine): Unit = { // TODO #19 Should we delete the old graph database? val otherStateMachine = other.asInstanceOf[GuiStateMachineNeo4J] - uri = otherStateMachine.uri + config = otherStateMachine.config + sessionFactory = otherStateMachine.sessionFactory } private[neo4j] def getNodeBySutStateIdentifier(session: Session, sutStateIdentifier: SutStateIdentifier): Option[SutStateEntity] = { diff --git a/src/main/scala/de/retest/guistatemachine/api/neo4j/Neo4JConfig.scala b/src/main/scala/de/retest/guistatemachine/api/neo4j/Neo4JConfig.scala new file mode 100644 index 0000000..49664c0 --- /dev/null +++ b/src/main/scala/de/retest/guistatemachine/api/neo4j/Neo4JConfig.scala @@ -0,0 +1,21 @@ +package de.retest.guistatemachine.api.neo4j + +import java.nio.file.Path + +import org.neo4j.ogm.config.Configuration + +sealed trait Neo4JConfig { + def buildConfig(): Configuration +} + +case class EmbeddedConfig(path: Path) extends Neo4JConfig { + override def buildConfig(): Configuration = new Configuration.Builder().uri(path.toUri.toString).build +} + +case class BoltConfig(host: String, port: Int, user: String, password: String) extends Neo4JConfig { + override def buildConfig(): Configuration = + new Configuration.Builder() + .uri(s"bolt://$host:$port") + .credentials(user, password) + .build() +} diff --git a/src/main/scala/de/retest/guistatemachine/api/neo4j/Neo4JSessionFactory.scala b/src/main/scala/de/retest/guistatemachine/api/neo4j/Neo4JSessionFactory.scala new file mode 100644 index 0000000..59d17ed --- /dev/null +++ b/src/main/scala/de/retest/guistatemachine/api/neo4j/Neo4JSessionFactory.scala @@ -0,0 +1,46 @@ +package de.retest.guistatemachine.api.neo4j + +import com.typesafe.scalalogging.Logger +import org.neo4j.ogm.session.SessionFactory + +import scala.collection.concurrent.TrieMap + +sealed trait Neo4JSessionFactory + +object Neo4JSessionFactory { + private val logger = Logger[Neo4JSessionFactory] + + /** + * Session factories should always be shared in the application. Besides, we have to avoid exceptions like: + * ``` + * org.neo4j.kernel.StoreLockException: Unable to obtain lock on store lock file: /tmp/GuiStateMachineApiNeo4jSpec8181209634775261316/store_lock. + * Please ensure no other process is using this database, and that the directory is writable (required even for read-only access) + * ``` + * Apparently, Neo4J does not support multiple processes to access the same embedded database. + * TODO #19 Can we allow access by multiple processes on the same database. + * TODO #19 When do we close this? + */ + private val sessionFactories = TrieMap[Neo4JConfig, SessionFactory]() + + def getSessionFactory(neo4JConfig: Neo4JConfig): SessionFactory = sessionFactories.get(neo4JConfig) match { + case Some(sessionFactory) => + logger.info("Reusing session factory for {}", neo4JConfig) + sessionFactory + case None => + logger.info("Creating new session factory for {}", neo4JConfig) + val conf = neo4JConfig.buildConfig() + val packageName = this.getClass.getPackage.getName + val sessionFactory = new SessionFactory(conf, packageName) + sessionFactories += (neo4JConfig -> sessionFactory) + sessionFactory + + } + + def removeSessionFactory(neo4JConfig: Neo4JConfig): Boolean = sessionFactories.remove(neo4JConfig) match { + case Some(sessionFactory) => + logger.info("Closing session factory for {}", neo4JConfig) + sessionFactory.close() + true + case None => false + } +} diff --git a/src/main/scala/de/retest/guistatemachine/api/neo4j/Neo4JUtil.scala b/src/main/scala/de/retest/guistatemachine/api/neo4j/Neo4JUtil.scala new file mode 100644 index 0000000..0abd62e --- /dev/null +++ b/src/main/scala/de/retest/guistatemachine/api/neo4j/Neo4JUtil.scala @@ -0,0 +1,25 @@ +package de.retest.guistatemachine.api.neo4j + +import org.neo4j.ogm.session.{Session, SessionFactory} +import org.neo4j.ogm.transaction.Transaction + +object Neo4JUtil { + + def transaction[A](f: Session => A)(implicit sessionFactory: SessionFactory): A = { + // We have to create a session for every transaction since sessions are not thread-safe. + val session = sessionFactory.openSession() + var txn: Option[Transaction] = None + try { + val transaction = session.beginTransaction() + txn = Some(transaction) + val r = f(session) + transaction.commit() + r + } finally { + txn match { + case Some(transaction) => transaction.close() + case None => + } + } + } +} diff --git a/src/main/scala/de/retest/guistatemachine/api/neo4j/Neo4jSessionFactory.scala b/src/main/scala/de/retest/guistatemachine/api/neo4j/Neo4jSessionFactory.scala deleted file mode 100644 index 468c953..0000000 --- a/src/main/scala/de/retest/guistatemachine/api/neo4j/Neo4jSessionFactory.scala +++ /dev/null @@ -1,52 +0,0 @@ -package de.retest.guistatemachine.api.neo4j - -import org.neo4j.ogm.config.Configuration -import org.neo4j.ogm.session.{Session, SessionFactory} -import org.neo4j.ogm.transaction.Transaction - -import scala.collection.concurrent.TrieMap - -object Neo4jSessionFactory { - private val sessionFactories = TrieMap[String, SessionFactory]() - - def getSessionFactoryEmbedded(uri: String): SessionFactory = sessionFactories.get(uri) match { - case Some(sessionFactory) => sessionFactory - case None => - // TODO #19 This must not overwrite an existing database! Actually, one should use one shared session factory but we distinguish between directories. - val conf = new Configuration.Builder().uri(uri).build - val sessionFactory = new SessionFactory(conf, this.getClass.getPackage.getName) - sessionFactories += (uri -> sessionFactory) - sessionFactory - } - - def getSessionFactoryBolt(uri: String): SessionFactory = sessionFactories.get(uri) match { - case Some(sessionFactory) => sessionFactory - case None => - // TODO #19 Retrieve server and login information from some user-defined config. - val conf = new Configuration.Builder() - .uri("bolt://localhost:7687") - .credentials("neo4j", "bla") - .build() - val sessionFactory = new SessionFactory(conf, this.getClass.getPackage.getName) - sessionFactories += (uri -> sessionFactory) - sessionFactory - } - - def transaction[A](f: Session => A)(implicit uri: String): A = { - // We have to create a session for every transaction since sessions are not thread-safe. - val session = getSessionFactoryEmbedded(uri).openSession() - var txn: Option[Transaction] = None - try { - val transaction = session.beginTransaction() - txn = Some(transaction) - val r = f(session) - transaction.commit() - r - } finally { - txn match { - case Some(transaction) => transaction.close() - case None => - } - } - } -} diff --git a/src/main/scala/de/retest/guistatemachine/api/neo4j/StateNeo4J.scala b/src/main/scala/de/retest/guistatemachine/api/neo4j/StateNeo4J.scala index b5f16d0..e392135 100644 --- a/src/main/scala/de/retest/guistatemachine/api/neo4j/StateNeo4J.scala +++ b/src/main/scala/de/retest/guistatemachine/api/neo4j/StateNeo4J.scala @@ -9,7 +9,7 @@ case class StateNeo4J(sutStateIdentifier: SutStateIdentifier, guiStateMachine: G override def getSutStateIdentifier: SutStateIdentifier = sutStateIdentifier override def getOutgoingActionTransitions: Map[ActionIdentifier, ActionTransitions] = - Neo4jSessionFactory.transaction { session => + Neo4JUtil.transaction { session => val sutStateEntity = getSutStateEntity(session) var result = HashMap[ActionIdentifier, ActionTransitions]() val iterator = sutStateEntity.outgoingActionTransitions.iterator() @@ -26,10 +26,10 @@ case class StateNeo4J(sutStateIdentifier: SutStateIdentifier, guiStateMachine: G result = result + (action -> actionTransitions) } result - }(guiStateMachine.uri) + }(guiStateMachine.sessionFactory) def getIncomingActionTransitions: Map[ActionIdentifier, ActionTransitions] = - Neo4jSessionFactory.transaction { session => + Neo4JUtil.transaction { session => val sutStateEntity = getSutStateEntity(session) var result = HashMap[ActionIdentifier, ActionTransitions]() val iterator = sutStateEntity.incomingActionTransitions.iterator() @@ -46,10 +46,10 @@ case class StateNeo4J(sutStateIdentifier: SutStateIdentifier, guiStateMachine: G result = result + (action -> actionTransitions) } result - }(guiStateMachine.uri) + }(guiStateMachine.sessionFactory) private[api] override def addTransition(a: ActionIdentifier, to: State): Unit = - Neo4jSessionFactory.transaction { session => + Neo4JUtil.transaction { session => val sourceState = getSutStateEntity(session) val targetSutStateIdentifier = to.asInstanceOf[StateNeo4J].sutStateIdentifier val targetState = guiStateMachine.getNodeBySutStateIdentifierOrThrow(session, targetSutStateIdentifier) @@ -63,7 +63,7 @@ case class StateNeo4J(sutStateIdentifier: SutStateIdentifier, guiStateMachine: G val transition = new ActionTransitionEntity(sourceState, targetState, a) session.save(transition) } - }(guiStateMachine.uri) + }(guiStateMachine.sessionFactory) private def getSutStateEntity(session: Session): SutStateEntity = guiStateMachine.getNodeBySutStateIdentifierOrThrow(session, sutStateIdentifier) } diff --git a/src/test/scala/de/retest/guistatemachine/api/neo4j/GuiStateMachineApiNeo4jSpec.scala b/src/test/scala/de/retest/guistatemachine/api/neo4j/GuiStateMachineApiNeo4jEmbeddedSpec.scala similarity index 77% rename from src/test/scala/de/retest/guistatemachine/api/neo4j/GuiStateMachineApiNeo4jSpec.scala rename to src/test/scala/de/retest/guistatemachine/api/neo4j/GuiStateMachineApiNeo4jEmbeddedSpec.scala index 216d5f2..12881ab 100644 --- a/src/test/scala/de/retest/guistatemachine/api/neo4j/GuiStateMachineApiNeo4jSpec.scala +++ b/src/test/scala/de/retest/guistatemachine/api/neo4j/GuiStateMachineApiNeo4jEmbeddedSpec.scala @@ -5,10 +5,10 @@ import java.nio.file.Files import de.retest.guistatemachine.api.{AbstractGuiStateMachineApiSpec, GuiStateMachineApi} import org.scalatest.BeforeAndAfterAll -class GuiStateMachineApiNeo4jSpec extends AbstractGuiStateMachineApiSpec with BeforeAndAfterAll { +class GuiStateMachineApiNeo4jEmbeddedSpec extends AbstractGuiStateMachineApiSpec with BeforeAndAfterAll { private val tempDir = Files.createTempDirectory("GuiStateMachineApiNeo4jSpec").toFile override def getName: String = "GuiStateMachineApiNeo4J" - override def getCut: GuiStateMachineApi = new GuiStateMachineApiNeo4J(tempDir.getAbsolutePath) + override def getCut: GuiStateMachineApi = new GuiStateMachineApiNeo4JEmbedded(tempDir.toPath) override def afterAll(): Unit = { tempDir.delete()