Skip to content

Commit 4a54ae5

Browse files
authored
ENT-4493 schema migration refactor (corda#6313)
ENT-4493 Refactor SchemaMigration so it can be open harmonised with Enterprise and can be customised.
1 parent 48cd263 commit 4a54ae5

File tree

3 files changed

+97
-66
lines changed

3 files changed

+97
-66
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package net.corda.nodeapi.internal.persistence
2+
3+
import liquibase.database.Database
4+
import liquibase.database.jvm.JdbcConnection
5+
6+
interface LiquibaseDatabaseFactory {
7+
fun getLiquibaseDatabase(conn: JdbcConnection): Database
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package net.corda.nodeapi.internal.persistence
2+
3+
import liquibase.database.Database
4+
import liquibase.database.DatabaseFactory
5+
import liquibase.database.jvm.JdbcConnection
6+
7+
class LiquibaseDatabaseFactoryImpl : LiquibaseDatabaseFactory {
8+
override fun getLiquibaseDatabase(conn: JdbcConnection): Database {
9+
return DatabaseFactory.getInstance().findCorrectDatabaseImplementation(conn)
10+
}
11+
}

node-api/src/main/kotlin/net/corda/nodeapi/internal/persistence/SchemaMigration.kt

+78-66
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,25 @@ import com.fasterxml.jackson.databind.ObjectMapper
44
import liquibase.Contexts
55
import liquibase.LabelExpression
66
import liquibase.Liquibase
7-
import liquibase.database.Database
8-
import liquibase.database.DatabaseFactory
97
import liquibase.database.jvm.JdbcConnection
8+
import liquibase.exception.LiquibaseException
109
import liquibase.resource.ClassLoaderResourceAccessor
1110
import net.corda.core.identity.CordaX500Name
12-
import net.corda.nodeapi.internal.MigrationHelpers.getMigrationResource
1311
import net.corda.core.schemas.MappedSchema
1412
import net.corda.core.utilities.contextLogger
13+
import net.corda.nodeapi.internal.MigrationHelpers.getMigrationResource
1514
import net.corda.nodeapi.internal.cordapp.CordappLoader
1615
import java.io.ByteArrayInputStream
1716
import java.io.InputStream
1817
import java.nio.file.Path
18+
import java.sql.Connection
1919
import java.sql.Statement
20-
import javax.sql.DataSource
2120
import java.util.concurrent.locks.ReentrantLock
21+
import javax.sql.DataSource
2222
import kotlin.concurrent.withLock
2323

2424
// Migrate the database to the current version, using liquibase.
25-
class SchemaMigration(
25+
open class SchemaMigration(
2626
val schemas: Set<MappedSchema>,
2727
val dataSource: DataSource,
2828
cordappLoader: CordappLoader? = null,
@@ -33,14 +33,16 @@ class SchemaMigration(
3333
private val ourName: CordaX500Name? = null,
3434
// This parameter forces an error to be thrown if there are missing migrations. When using H2, Hibernate will automatically create schemas where they are
3535
// missing, so no need to throw unless you're specifically testing whether all the migrations are present.
36-
private val forceThrowOnMissingMigration: Boolean = false) {
36+
private val forceThrowOnMissingMigration: Boolean = false,
37+
protected val databaseFactory: LiquibaseDatabaseFactory = LiquibaseDatabaseFactoryImpl()) {
3738

3839
companion object {
3940
private val logger = contextLogger()
4041
const val NODE_BASE_DIR_KEY = "liquibase.nodeDaseDir"
4142
const val NODE_X500_NAME = "liquibase.nodeName"
4243
val loader = ThreadLocal<CordappLoader>()
43-
private val mutex = ReentrantLock()
44+
@JvmStatic
45+
protected val mutex = ReentrantLock()
4446
}
4547

4648
init {
@@ -49,25 +51,54 @@ class SchemaMigration(
4951

5052
private val classLoader = cordappLoader?.appClassLoader ?: Thread.currentThread().contextClassLoader
5153

52-
/**
54+
/**
5355
* Will run the Liquibase migration on the actual database.
5456
*/
55-
fun runMigration(existingCheckpoints: Boolean) {
56-
migrateOlderDatabaseToUseLiquibase(existingCheckpoints)
57-
doRunMigration(run = true, check = false, existingCheckpoints = existingCheckpoints)
58-
}
57+
fun runMigration(existingCheckpoints: Boolean) {
58+
migrateOlderDatabaseToUseLiquibase(existingCheckpoints)
59+
val resourcesAndSourceInfo = prepareResources()
60+
61+
// current version of Liquibase appears to be non-threadsafe
62+
// this is apparent when multiple in-process nodes are all running migrations simultaneously
63+
mutex.withLock {
64+
dataSource.connection.use { connection ->
65+
val (runner, _, shouldBlockOnCheckpoints) = prepareRunner(connection, resourcesAndSourceInfo)
66+
if (shouldBlockOnCheckpoints && existingCheckpoints)
67+
throw CheckpointsException()
68+
try {
69+
runner.update(Contexts().toString())
70+
} catch (exp: LiquibaseException) {
71+
throw DatabaseMigrationException(exp.message, exp)
72+
}
73+
}
74+
}
75+
}
5976

6077
/**
6178
* Ensures that the database is up to date with the latest migration changes.
6279
*/
63-
fun checkState() = doRunMigration(run = false, check = true)
80+
fun checkState() {
81+
val resourcesAndSourceInfo = prepareResources()
6482

65-
/** Create a resourse accessor that aggregates the changelogs included in the schemas into one dynamic stream. */
66-
private class CustomResourceAccessor(val dynamicInclude: String, val changelogList: List<String?>, classLoader: ClassLoader) : ClassLoaderResourceAccessor(classLoader) {
83+
// current version of Liquibase appears to be non-threadsafe
84+
// this is apparent when multiple in-process nodes are all running migrations simultaneously
85+
mutex.withLock {
86+
dataSource.connection.use { connection ->
87+
val (_, changeToRunCount, _) = prepareRunner(connection, resourcesAndSourceInfo)
88+
if (changeToRunCount > 0)
89+
throw OutstandingDatabaseChangesException(changeToRunCount)
90+
}
91+
}
92+
}
93+
94+
/** Create a resource accessor that aggregates the changelogs included in the schemas into one dynamic stream. */
95+
protected class CustomResourceAccessor(val dynamicInclude: String, val changelogList: List<String?>, classLoader: ClassLoader) :
96+
ClassLoaderResourceAccessor(classLoader) {
6797
override fun getResourcesAsStream(path: String): Set<InputStream> {
6898
if (path == dynamicInclude) {
6999
// Create a map in Liquibase format including all migration files.
70-
val includeAllFiles = mapOf("databaseChangeLog" to changelogList.filter { it != null }.map { file -> mapOf("include" to mapOf("file" to file)) })
100+
val includeAllFiles = mapOf("databaseChangeLog"
101+
to changelogList.filterNotNull().map { file -> mapOf("include" to mapOf("file" to file)) })
71102

72103
// Transform it to json.
73104
val includeAllFilesJson = ObjectMapper().writeValueAsBytes(includeAllFiles)
@@ -87,59 +118,40 @@ class SchemaMigration(
87118
null
88119
}
89120

90-
private fun doRunMigration(
91-
run: Boolean,
92-
check: Boolean,
93-
existingCheckpoints: Boolean? = null
94-
) {
95-
96-
// Virtual file name of the changelog that includes all schemas.
97-
val dynamicInclude = "master.changelog.json"
98-
99-
dataSource.connection.use { connection ->
100-
101-
// Collect all changelog files referenced in the included schemas.
102-
val changelogList = schemas.mapNotNull { mappedSchema ->
103-
val resource = getMigrationResource(mappedSchema, classLoader)
104-
when {
105-
resource != null -> resource
106-
// Corda OS FinanceApp in v3 has no Liquibase script, so no error is raised
107-
(mappedSchema::class.qualifiedName == "net.corda.finance.schemas.CashSchemaV1" || mappedSchema::class.qualifiedName == "net.corda.finance.schemas.CommercialPaperSchemaV1") && mappedSchema.migrationResource == null -> null
108-
else -> logOrThrowMigrationError(mappedSchema)
109-
}
121+
// Virtual file name of the changelog that includes all schemas.
122+
val dynamicInclude = "master.changelog.json"
123+
124+
protected fun prepareResources(): List<Pair<CustomResourceAccessor, String>> {
125+
// Collect all changelog files referenced in the included schemas.
126+
val changelogList = schemas.mapNotNull { mappedSchema ->
127+
val resource = getMigrationResource(mappedSchema, classLoader)
128+
when {
129+
resource != null -> resource
130+
// Corda OS FinanceApp in v3 has no Liquibase script, so no error is raised
131+
(mappedSchema::class.qualifiedName == "net.corda.finance.schemas.CashSchemaV1" || mappedSchema::class.qualifiedName == "net.corda.finance.schemas.CommercialPaperSchemaV1") && mappedSchema.migrationResource == null -> null
132+
else -> logOrThrowMigrationError(mappedSchema)
110133
}
134+
}
111135

112-
val path = currentDirectory?.toString()
113-
if (path != null) {
114-
System.setProperty(NODE_BASE_DIR_KEY, path) // base dir for any custom change set which may need to load a file (currently AttachmentVersionNumberMigration)
115-
}
116-
if (ourName != null) {
117-
System.setProperty(NODE_X500_NAME, ourName.toString())
118-
}
119-
val customResourceAccessor = CustomResourceAccessor(dynamicInclude, changelogList, classLoader)
120-
checkResourcesInClassPath(changelogList)
121-
122-
// current version of Liquibase appears to be non-threadsafe
123-
// this is apparent when multiple in-process nodes are all running migrations simultaneously
124-
mutex.withLock {
125-
val liquibase = Liquibase(dynamicInclude, customResourceAccessor, getLiquibaseDatabase(JdbcConnection(connection)))
126-
127-
val unRunChanges = liquibase.listUnrunChangeSets(Contexts(), LabelExpression())
128-
129-
when {
130-
(run && !check) && (unRunChanges.isNotEmpty() && existingCheckpoints!!) -> throw CheckpointsException() // Do not allow database migration when there are checkpoints
131-
run && !check -> liquibase.update(Contexts())
132-
check && !run && unRunChanges.isNotEmpty() -> throw OutstandingDatabaseChangesException(unRunChanges.size)
133-
check && !run -> {
134-
} // Do nothing will be interpreted as "check succeeded"
135-
else -> throw IllegalStateException("Invalid usage.")
136-
}
137-
}
136+
val path = currentDirectory?.toString()
137+
if (path != null) {
138+
System.setProperty(NODE_BASE_DIR_KEY, path) // base dir for any custom change set which may need to load a file (currently AttachmentVersionNumberMigration)
138139
}
140+
if (ourName != null) {
141+
System.setProperty(NODE_X500_NAME, ourName.toString())
142+
}
143+
val customResourceAccessor = CustomResourceAccessor(dynamicInclude, changelogList, classLoader)
144+
checkResourcesInClassPath(changelogList)
145+
return listOf(Pair(customResourceAccessor, ""))
139146
}
140147

141-
private fun getLiquibaseDatabase(conn: JdbcConnection): Database {
142-
return DatabaseFactory.getInstance().findCorrectDatabaseImplementation(conn)
148+
protected fun prepareRunner(connection: Connection,
149+
resourcesAndSourceInfo: List<Pair<CustomResourceAccessor, String>>): Triple<Liquibase, Int, Boolean> {
150+
require(resourcesAndSourceInfo.size == 1)
151+
val liquibase = Liquibase(dynamicInclude, resourcesAndSourceInfo.single().first, databaseFactory.getLiquibaseDatabase(JdbcConnection(connection)))
152+
153+
val unRunChanges = liquibase.listUnrunChangeSets(Contexts(), LabelExpression())
154+
return Triple(liquibase, unRunChanges.size, !unRunChanges.isEmpty())
143155
}
144156

145157
/** For existing database created before verions 4.0 add Liquibase support - creates DATABASECHANGELOG and DATABASECHANGELOGLOCK tables and marks changesets as executed. */
@@ -219,7 +231,7 @@ class SchemaMigration(
219231
checkResourcesInClassPath(preV4Baseline)
220232
dataSource.connection.use { connection ->
221233
val customResourceAccessor = CustomResourceAccessor(dynamicInclude, preV4Baseline, classLoader)
222-
val liquibase = Liquibase(dynamicInclude, customResourceAccessor, getLiquibaseDatabase(JdbcConnection(connection)))
234+
val liquibase = Liquibase(dynamicInclude, customResourceAccessor, databaseFactory.getLiquibaseDatabase(JdbcConnection(connection)))
223235
liquibase.changeLogSync(Contexts(), LabelExpression())
224236
}
225237
}
@@ -235,7 +247,7 @@ class SchemaMigration(
235247
}
236248
}
237249

238-
open class DatabaseMigrationException(message: String) : IllegalArgumentException(message) {
250+
open class DatabaseMigrationException(message: String?, cause: Throwable? = null) : IllegalArgumentException(message, cause) {
239251
override val message: String = super.message!!
240252
}
241253

0 commit comments

Comments
 (0)