Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ package org.apache.texera.amber.engine.e2e

import com.twitter.util.{Await, Duration, Promise, Return, Throw, Try}
import org.apache.pekko.actor.ActorSystem
import org.apache.texera.common.config.StorageConfig
import org.apache.texera.amber.core.executor.OpExecInitInfo
import org.apache.texera.amber.core.storage.DocumentFactory
import org.apache.texera.amber.core.storage.model.VirtualDocument
Expand Down Expand Up @@ -199,10 +198,28 @@ object TestUtils {
* Note such test cases need to clean up the database at the end of running each test case.
*/
def initiateTexeraDBForTestCases(): Unit = {
org.apache.texera.dao.MockTexeraDB.ensureInitialized()
val embedded = org.apache.texera.dao.MockTexeraDB.getDBInstance

val dbName = "texera_db_for_test_cases_" + java.util.UUID.randomUUID().toString.replace("-", "")

scala.util.Using.resource(embedded.getPostgresDatabase.getConnection) { conn =>
scala.util.Using.resource(conn.createStatement()) { stmt =>
stmt.execute(s"DROP DATABASE IF EXISTS $dbName")
stmt.execute(s"CREATE DATABASE $dbName")
}
}

val targetDbConn = embedded.getDatabase("postgres", dbName).getConnection

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few things in this method: (1) it duplicates the "create DB + run DDL" sequence from the trait (MockTexeraDB.scala:101-114) and the copies have already drifted — worth one shared helper on object MockTexeraDB. (2) targetDbConn is closed manually after the Using.resource, so it leaks if execute throws — wrap it in Using.resource too. (3) The DROP DATABASE IF EXISTS on a freshly generated UUID name (line 208) is dead. (4) This quietly moves the e2e specs off the external test Postgres onto the embedded one — a real (good) change worth a line in the description; it depends on #4179. I ran the e2e specs this way on JDK 11 (46 tests ×3, no Connection refused), so the switch looks safe for sequential runs.

scala.util.Using.resource(targetDbConn.createStatement()) { stmt =>
stmt.execute(org.apache.texera.dao.MockTexeraDB.getDDLScript)
}
targetDbConn.close()

SqlServer.initConnection(
StorageConfig.jdbcUrlForTestCases,
StorageConfig.jdbcUsername,
StorageConfig.jdbcPassword
embedded.getJdbcUrl("postgres", dbName),
"postgres",
""
)
}

Expand Down
269 changes: 148 additions & 121 deletions common/dao/src/test/scala/org/apache/texera/dao/MockTexeraDB.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,146 +19,173 @@

package org.apache.texera.dao

import com.zaxxer.hikari.{HikariConfig, HikariDataSource}
import io.zonky.test.db.postgres.embedded.EmbeddedPostgres
import org.jooq.impl.DSL
import org.jooq.impl.{DSL, DataSourceConnectionProvider, DefaultConfiguration}
import org.jooq.{DSLContext, SQLDialect}
import org.scalatest.{Outcome, TestSuite, TestSuiteMixin}

import java.nio.file.Paths
import java.sql.{Connection, DriverManager}
import java.sql.DriverManager
import scala.io.Source

trait MockTexeraDB {

private var dbInstance: Option[EmbeddedPostgres] = None
private var dslContext: Option[DSLContext] = None
private val database: String = "texera_db"
import scala.util.Using

/**
* Provides a JVM-singleton EmbeddedPostgres for tests. Multiple specs that mix

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This credits the singleton with avoiding OverlappingFileLockException "in parallel," but tests still run sequentially (Tags.limit is in place), so the actual current win is booting Postgres once. Worth aligning the comment with today's behavior.

* in this trait share one Postgres instance for the lifetime of the JVM, which
* avoids the OverlappingFileLockException that occurs when each spec tries to
* extract the embedded Postgres binaries into the same directory in parallel.
*/
object MockTexeraDB {
private val username: String = "postgres"
private val password: String = ""

def executeScriptInJDBC(conn: Connection, script: String): Unit = {
assert(dbInstance.nonEmpty)
conn.prepareStatement(script).execute()
conn.close()
}
@volatile private var dbInstance: Option[EmbeddedPostgres] = None
@volatile private var ddlScript: Option[String] = None

def getDSLContext: DSLContext = {
dslContext match {
case Some(value) => value
case None =>
throw new RuntimeException(
"test database is not initialized. Did you call initializeDBAndReplaceDSLContext()?"
)
}
}
def ensureInitialized(): Unit =
synchronized {
if (dbInstance.isDefined && ddlScript.isDefined) return

def getDBInstance: EmbeddedPostgres = {
dbInstance match {
case Some(value) => value
case None =>
throw new RuntimeException(
"test database is not initialized. Did you call initializeDBAndReplaceDSLContext()?"
)
}
}
if (dbInstance.isEmpty) {
val driver = new org.postgresql.Driver()
DriverManager.registerDriver(driver)

def shutdownDB(): Unit = {
dbInstance match {
case Some(value) =>
value.close()
dbInstance = None
dslContext = None
case None =>
// do nothing
}
}
// Boot the heavy JVM engine exactly once
dbInstance = Some(EmbeddedPostgres.builder().start())
}

val ddlPath = Paths.get("sql/texera_ddl.sql").toRealPath()
val source = Source.fromFile(ddlPath.toString)
val content =
try source.mkString
finally source.close()

def initializeDBAndReplaceDSLContext(): Unit = {
assert(dbInstance.isEmpty && dslContext.isEmpty)
val parts: Array[String] = content.split("(?m)^CREATE DATABASE :\"DB_NAME\";")
val sqlBody = if (parts.length > 1) parts(1) else content

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the CREATE DATABASE split marker ever changes, parts.length > 1 is false and this silently runs the whole DDL as the body. A hard failure would be safer than quietly doing the wrong thing.


val driver = new org.postgresql.Driver()
DriverManager.registerDriver(driver)
def removeCCommands(sql: String): String =
sql.linesIterator.filterNot(_.trim.startsWith("\\c")).mkString("\n")

val embedded = EmbeddedPostgres.builder().start()
var tablesAndIndexCreation = removeCCommands(sqlBody)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tablesAndIndexCreation is never reassigned — can be a val.


dbInstance = Some(embedded)
val blockPattern =
"""(?s)-- START Fulltext search index creation \(DO NOT EDIT THIS LINE\).*?-- END Fulltext search index creation \(DO NOT EDIT THIS LINE\)\n?""".r
val replacementText =
"""CREATE INDEX idx_workflow_name_description_content ON workflow USING GIN (to_tsvector('english', COALESCE(name, '') || ' ' || COALESCE(description, '') || ' ' || COALESCE(content, '')));
|CREATE INDEX idx_user_name ON "user" USING GIN (to_tsvector('english', COALESCE(name, '')));
|CREATE INDEX idx_user_project_name_description ON project USING GIN (to_tsvector('english', COALESCE(name, '') || ' ' || COALESCE(description, '')));
|CREATE INDEX idx_dataset_name_description ON dataset USING GIN (to_tsvector('english', COALESCE(name, '') || ' ' || COALESCE(description, '')));
|CREATE INDEX idx_dataset_version_name ON dataset_version USING GIN (to_tsvector('english', COALESCE(name, '')));""".stripMargin

val ddlPath = {
Paths.get("sql/texera_ddl.sql").toRealPath()
// Cache the cleaned script so parallel suites don't have to re-read the file
ddlScript = Some(blockPattern.replaceAllIn(tablesAndIndexCreation, replacementText).trim)
}
val source = Source.fromFile(ddlPath.toString)
val content =
try {
source.mkString
} finally {
source.close()

def getDBInstance: EmbeddedPostgres =
dbInstance.getOrElse(throw new RuntimeException("DB not initialized"))
def getDDLScript: String = ddlScript.getOrElse(throw new RuntimeException("DDL not loaded"))
}

trait MockTexeraDB extends TestSuiteMixin { this: TestSuite =>
private var testScopedContext: Option[DSLContext] = None
protected var dataSource: Option[HikariDataSource] = None
protected var uniqueDbName: String = ""

def initializeDBAndReplaceDSLContext(): Unit =
synchronized {
if (dataSource.isEmpty || dataSource.get.isClosed) {
MockTexeraDB.ensureInitialized()
val embedded = MockTexeraDB.getDBInstance

uniqueDbName = "texera_db_" + java.util.UUID.randomUUID().toString.replace("-", "")
Using.resource(embedded.getPostgresDatabase.getConnection) { defaultConn =>
Using.resource(defaultConn.createStatement()) { stmt =>
stmt.execute(s"CREATE DATABASE $uniqueDbName")
}
}

// Run the DDL once via a throwaway connection (autoCommit is TRUE by default,
// so the schema is permanently committed to this suite's isolated database).
Using.resource(embedded.getDatabase("postgres", uniqueDbName).getConnection) { conn =>
Using.resource(conn.createStatement()) { stmt =>
stmt.execute(MockTexeraDB.getDDLScript)
}
}

val jdbcUrl = embedded.getJdbcUrl("postgres", uniqueDbName)

// Back the test DSLContext with a real HikariCP pool so that concurrent
// transactions acquire *distinct* connections (matching production), rather
// than trampling one shared connection's autoCommit flag.
val hikariConfig = new HikariConfig()
hikariConfig.setJdbcUrl(jdbcUrl)
hikariConfig.setUsername("postgres")
hikariConfig.setPassword("")
// Must exceed the maximum number of concurrent test threads.
hikariConfig.setMaximumPoolSize(10)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10 is justified by "must exceed the max concurrent test threads," but nothing enforces that and exceeding it blocks 30s then fails. A named constant with the rationale (or deriving it) would age better.

val ds = new HikariDataSource(hikariConfig)
dataSource = Some(ds)

val jooqCfg = new DefaultConfiguration()
jooqCfg.set(new DataSourceConnectionProvider(ds))
jooqCfg.set(SQLDialect.POSTGRES)
val scopedCtx = DSL.using(jooqCfg)
testScopedContext = Some(scopedCtx)

// Point the Texera backend exactly to this suite's isolated database
SqlServer.initConnection(jdbcUrl, "postgres", "")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SqlServer.initConnection constructs a SqlServer whose constructor eagerly opens its own 10-connection Hikari pool (SqlServer.scala:62, minimumIdle=2), and the next line swaps in the trait's separate pool. So each trait suite opens two pools and the SqlServer-owned one is never used. Consider a SqlServer method that repoints its existing pool, or reuse that pool instead of building a new DataSourceConnectionProvider.

SqlServer.getInstance().replaceDSLContext(scopedCtx)
}
val parts: Array[String] = content.split("(?m)^CREATE DATABASE :\"DB_NAME\";")
def removeCCommands(sql: String): String =
sql.linesIterator
.filterNot(_.trim.startsWith("\\c"))
.mkString("\n")
val createDBStatement =
"""DROP DATABASE IF EXISTS texera_db;
|CREATE DATABASE texera_db;""".stripMargin
executeScriptInJDBC(embedded.getPostgresDatabase.getConnection, createDBStatement)
val texeraDB = embedded.getDatabase(username, database)
var tablesAndIndexCreation = removeCCommands(parts(1))

// remove indexes creation for pgroonga because we cannot install the plugin
val blockPattern =
"""(?s)-- START Fulltext search index creation \(DO NOT EDIT THIS LINE\).*?-- END Fulltext search index creation \(DO NOT EDIT THIS LINE\)\n?""".r
// replace with native fulltext indexes
val replacementText =
"""CREATE INDEX idx_workflow_name_description_content
| ON workflow
| USING GIN (
| to_tsvector('english',
| COALESCE(name, '') || ' ' ||
| COALESCE(description, '') || ' ' ||
| COALESCE(content, '')
| )
| );
|
|CREATE INDEX idx_user_name
| ON "user"
| USING GIN (
| to_tsvector('english',
| COALESCE(name, '')
| )
| );
|
|CREATE INDEX idx_user_project_name_description
| ON project
| USING GIN (
| to_tsvector('english',
| COALESCE(name, '') || ' ' ||
| COALESCE(description, '')
| )
| );
|
|CREATE INDEX idx_dataset_name_description
| ON dataset
| USING GIN (
| to_tsvector('english',
| COALESCE(name, '') || ' ' ||
| COALESCE(description, '')
| )
| );
|
|CREATE INDEX idx_dataset_version_name
| ON dataset_version
| USING GIN (
| to_tsvector('english',
| COALESCE(name, '')
| )
| );""".stripMargin

tablesAndIndexCreation = blockPattern.replaceAllIn(tablesAndIndexCreation, replacementText).trim
executeScriptInJDBC(texeraDB.getConnection, tablesAndIndexCreation)
SqlServer.initConnection(embedded.getJdbcUrl(username, database), username, password)
}

abstract override def withFixture(test: NoArgTest): Outcome = {
initializeDBAndReplaceDSLContext()

val sqlServerInstance = SqlServer.getInstance()
dslContext = Some(DSL.using(texeraDB, SQLDialect.POSTGRES))
val activeContext = testScopedContext.get

sqlServerInstance.replaceDSLContext(dslContext.get)
try {
sqlServerInstance.replaceDSLContext(activeContext)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per-suite isolation is real for getDSLContext, but production DAOs read through the single global SqlServer, which this repoints per test. That's safe only while Tags.limit(Tags.Test,1) keeps suites sequential (still in all 12 build.sbt). Once #4525 removes that, two suites in one JVM race on this global — and initConnection closing the prior instance's pool can drop a pool another suite is using. Worth noting this PR is groundwork; parallel-safety needs a follow-up that makes the context per-suite/thread.

super.withFixture(test)
} finally {
try {
// Truncate all tables on a pooled connection so each test starts clean.
Using.resource(dataSource.get.getConnection) { conn =>
Using.resource(conn.createStatement()) { stmt =>
stmt.execute(
"""
DO $$ DECLARE
r RECORD;
BEGIN
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This per-test cleanup is a silent no-op (targets the wrong schema). It truncates the public schema, but every table lives in texera_db (sql/texera_ddl.sql:42-43 creates that schema and sets search_path), so pg_tables WHERE schemaname='public' matches nothing. Verified on JDK 11: ComputingUnitAccessSpec and FileResolverSpec pass only because the beforeAll seed is never deleted; retargeting the truncation at texera_db makes ComputingUnitAccessSpec fail 3/5. Two consequences: (1) "each test starts clean" is false today — the trait adds no per-test isolation, which is a trap for anyone who later drops a suite's own cleanup trusting this; (2) you can't just fix the schema name, because that breaks every suite that seeds only in beforeAll (AccessControlResourceSpec, ComputingUnitAccessSpec, WorkflowResourceSpec, FileResolverSpec, and the DatasetResourceSpec this PR touches). Suggest either dropping the truncation and keeping each suite's existing cleanup, or making it real and moving those seeds to beforeEach.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think removing the truncation for now and leaving the truncation logic for later would be best? This allows us to get the speed-up now without ballooning the responsibilities of this PR?

EXECUTE 'TRUNCATE TABLE ' || quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END $$;
"""
)
}
}
} catch {
case e: Exception => e.printStackTrace()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the truncation throws it's only printed and swallowed, so the next test can start against dirty state. If the cleanup stays, a failure to clean should fail loudly rather than print-and-continue.

}
}
}

def getDSLContext: DSLContext =
synchronized {
if (testScopedContext.isEmpty) {
initializeDBAndReplaceDSLContext()
}
testScopedContext.get
}

def getDBInstance: EmbeddedPostgres = MockTexeraDB.getDBInstance

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor readability: getDBInstance exists on both the object and this trait, and there are two init entry points (ensureInitialized vs initializeDBAndReplaceDSLContext). A line of doc on each boundary would save a maintainer some head-scratching.


def shutdownDB(): Unit =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two small things: the name no longer matches its behavior (it closes the per-suite pool, not a DB), and the created databases are never dropped (fine now, matters once suites run in parallel). Also #6063 asked to no-op shutdownDB; closing the pool is better, so a one-line note that you deviated on purpose would help.

synchronized {
try dataSource.foreach(ds => if (!ds.isClosed) ds.close())
catch { case e: Exception => e.printStackTrace() }
finally { dataSource = None; testScopedContext = None }
}
}
Loading
Loading