Skip to content

Commit e6907b7

Browse files
committed
fix(model-server): add custom deletion logic for IgniteStoreClient
1 parent 0d76ae4 commit e6907b7

File tree

3 files changed

+157
-9
lines changed

3 files changed

+157
-9
lines changed

model-server-test/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@ plugins {
66
dependencies {
77
testImplementation(kotlin("test"))
88
testImplementation(project(":model-server"))
9+
testImplementation(project(":model-server").dependencyProject.sourceSets.test.get().runtimeClasspath)
910
}
1011

1112
tasks.test {
13+
dependsOn(":model-server:compileTestKotlin")
14+
useJUnitPlatform()
1215
doFirst {
1316
val db = dockerCompose.servicesInfos.getValue("db")
1417
systemProperty("jdbc.url", "jdbc:postgresql://${db.host}:${db.port}/")
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/*
2+
* Copyright (c) 2024.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import kotlinx.coroutines.runBlocking
17+
import kotlinx.coroutines.test.runTest
18+
import org.junit.jupiter.api.Test
19+
import org.modelix.model.lazy.RepositoryId
20+
import org.modelix.model.server.handlers.RepositoriesManagerTest
21+
import org.modelix.model.server.store.IgniteStoreClient
22+
import java.sql.DriverManager
23+
import kotlin.test.AfterTest
24+
import kotlin.test.assertFalse
25+
import kotlin.test.assertTrue
26+
27+
class RepositoriesManagerWithDatabaseTest : RepositoriesManagerTest(IgniteStoreClient()) {
28+
private fun getDbConnection() = DriverManager.getConnection(System.getProperty("jdbc.url"), "modelix", "modelix")
29+
30+
private val existingRepo = RepositoryId("existing")
31+
private val repoToBeDeleted = RepositoryId("tobedeleted")
32+
33+
@AfterTest
34+
fun deleteTestDataFromDatabase() {
35+
runBlocking {
36+
repoManager.removeRepository(existingRepo)
37+
repoManager.removeRepository(repoToBeDeleted)
38+
}
39+
getDbConnection().prepareStatement("DELETE FROM modelix.model WHERE repository IN (?,?)").use {
40+
it.setString(1, existingRepo.id)
41+
it.setString(2, repoToBeDeleted.id)
42+
it.execute()
43+
}
44+
}
45+
46+
@Test
47+
fun `database does not contain removed data that was not part of the cache`() = runTest {
48+
initRepository(repoToBeDeleted)
49+
50+
getDbConnection().use { connection ->
51+
connection.prepareStatement("INSERT INTO modelix.model (repository, key, value) VALUES (?, 'myKey', 'myValue')")
52+
.use {
53+
it.setString(1, repoToBeDeleted.id)
54+
it.execute()
55+
check(it.updateCount == 1)
56+
}
57+
58+
repoManager.removeRepository(repoToBeDeleted)
59+
60+
connection.prepareStatement("SELECT * FROM modelix.model WHERE repository = ?").use {
61+
it.setString(1, repoToBeDeleted.id)
62+
val result = it.executeQuery()
63+
assertFalse("Database contained leftover repository data.") { result.isBeforeFirst }
64+
}
65+
}
66+
}
67+
68+
@Test
69+
fun `removal does not affect other repository data in the database`() = runTest {
70+
initRepository(existingRepo)
71+
initRepository(repoToBeDeleted)
72+
73+
getDbConnection().use { connection ->
74+
connection.prepareStatement("INSERT INTO modelix.model (repository, key, value) VALUES (?, 'myKey', 'myValue')")
75+
.apply {
76+
setString(1, existingRepo.id)
77+
execute()
78+
check(updateCount == 1)
79+
close()
80+
}
81+
82+
repoManager.removeRepository(repoToBeDeleted)
83+
84+
val statement = connection.prepareStatement("SELECT * FROM modelix.model WHERE repository = ?").apply {
85+
setString(1, existingRepo.id)
86+
}
87+
statement.use {
88+
val result = it.executeQuery()
89+
assertTrue("Other repository data was removed from the database.") { result.isBeforeFirst }
90+
}
91+
}
92+
}
93+
}

model-server/src/main/kotlin/org/modelix/model/server/store/IgniteStoreClient.kt

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,29 @@ import mu.KotlinLogging
1818
import org.apache.ignite.Ignite
1919
import org.apache.ignite.IgniteCache
2020
import org.apache.ignite.Ignition
21+
import org.apache.ignite.cache.query.ScanQuery
22+
import org.apache.ignite.lang.IgniteBiPredicate
23+
import org.apache.ignite.lang.IgniteClosure
2124
import org.modelix.kotlin.utils.ContextValue
2225
import org.modelix.model.IGenericKeyListener
26+
import org.modelix.model.lazy.RepositoryId
2327
import org.modelix.model.persistent.HashUtil
2428
import org.modelix.model.server.SqlUtils
2529
import java.io.File
2630
import java.io.FileReader
2731
import java.io.IOException
32+
import java.sql.SQLException
2833
import java.util.*
34+
import javax.cache.Cache
2935
import javax.sql.DataSource
3036

3137
private val LOG = KotlinLogging.logger { }
3238

33-
class IgniteStoreClient(jdbcConfFile: File? = null, inmemory: Boolean = false) : IsolatingStore, AutoCloseable {
39+
/**
40+
* Store client implementation with an ignite cache.
41+
* If [inmemory] is true, the data is not persisted in a database.
42+
*/
43+
class IgniteStoreClient(jdbcConfFile: File? = null, private val inmemory: Boolean = false) : IsolatingStore, AutoCloseable {
3444

3545
companion object {
3646
private const val ENTRY_CHANGED_TOPIC = "entryChanged"
@@ -43,6 +53,14 @@ class IgniteStoreClient(jdbcConfFile: File? = null, inmemory: Boolean = false) :
4353
ignite.message().send(ENTRY_CHANGED_TOPIC, it)
4454
}
4555

56+
private val igniteConfigName: String = if (inmemory) "ignite-inmemory.xml" else "ignite.xml"
57+
private val dataSource: DataSource by lazy {
58+
Ignition.loadSpringBean(
59+
IgniteStoreClient::class.java.getResource(igniteConfigName),
60+
"dataSource",
61+
)
62+
}
63+
4664
/**
4765
* Instantiate an IgniteStoreClient
4866
*
@@ -74,8 +92,7 @@ class IgniteStoreClient(jdbcConfFile: File? = null, inmemory: Boolean = false) :
7492
)
7593
}
7694
}
77-
val igniteConfigName = if (inmemory) "ignite-inmemory.xml" else "ignite.xml"
78-
if (!inmemory) updateDatabaseSchema(igniteConfigName)
95+
if (!inmemory) updateDatabaseSchema()
7996
ignite = Ignition.start(javaClass.getResource(igniteConfigName))
8097
cache = ignite.getOrCreateCache("model")
8198

@@ -87,11 +104,7 @@ class IgniteStoreClient(jdbcConfFile: File? = null, inmemory: Boolean = false) :
87104
}
88105
}
89106

90-
private fun updateDatabaseSchema(igniteConfigName: String) {
91-
val dataSource: DataSource = Ignition.loadSpringBean<DataSource>(
92-
IgniteStoreClient::class.java.getResource(igniteConfigName),
93-
"dataSource",
94-
)
107+
private fun updateDatabaseSchema() {
95108
SqlUtils(dataSource.connection).ensureSchemaInitialization()
96109
}
97110

@@ -103,6 +116,44 @@ class IgniteStoreClient(jdbcConfFile: File? = null, inmemory: Boolean = false) :
103116
return cache.associate { it.key to it.value }
104117
}
105118

119+
override fun removeRepositoryObjects(repositoryId: RepositoryId) {
120+
if (!inmemory) {
121+
// Not all entries are in the cache. We delete them directly instead of loading them into the cache first.
122+
// This should be safe as the repository has already been removed from the list of available ones.
123+
removeRepositoryObjectsFromDatabase(repositoryId)
124+
}
125+
126+
val filter = IgniteBiPredicate<ObjectInRepository, String?> { key, _ ->
127+
key.getRepositoryId() == repositoryId.id
128+
}
129+
val transformer = IgniteClosure<Cache.Entry<ObjectInRepository, String?>, ObjectInRepository?> { entry ->
130+
entry.key
131+
}
132+
val query = ScanQuery(filter)
133+
134+
// sorting is necessary to avoid deadlocks, see documentation of IgniteCache::removeAllAsync
135+
val toDelete = cache.query(query, transformer).all.asSequence().filterNotNull().toSortedSet()
136+
LOG.info { "Deleting cache entries asynchronously. [numberOfEntries=${toDelete.size}]" }
137+
cache.removeAllAsync(toDelete).listen { LOG.info { "Cache entries deleted." } }
138+
}
139+
140+
private fun removeRepositoryObjectsFromDatabase(repositoryId: RepositoryId) {
141+
require(!inmemory) { "Cannot remove from database in in-memory mode." }
142+
LOG.info { "Removing repository objects from database." }
143+
144+
dataSource.connection.use { connection ->
145+
connection.prepareStatement("DELETE from model WHERE repository = ?").use { stmt ->
146+
stmt.setString(1, repositoryId.id)
147+
try {
148+
val deletedRows = stmt.executeUpdate()
149+
LOG.info { "Deleted rows from database. [deletedRows=$deletedRows]" }
150+
} catch (e: SQLException) {
151+
LOG.error { e }
152+
}
153+
}
154+
}
155+
}
156+
106157
override fun putAll(entries: Map<ObjectInRepository, String?>, silent: Boolean) {
107158
// Sorting is important to avoid deadlocks (lock ordering).
108159
// The documentation of IgniteCache.putAll also states that this a requirement.
@@ -174,7 +225,8 @@ class PendingChangeMessages(private val notifier: (ObjectInRepository) -> Unit)
174225
}
175226

176227
fun entryChanged(key: ObjectInRepository) {
177-
val messages = checkNotNull(pendingChangeMessages.getValueOrNull()) { "Only allowed inside PendingChangeMessages.runAndFlush" }
228+
val messages =
229+
checkNotNull(pendingChangeMessages.getValueOrNull()) { "Only allowed inside PendingChangeMessages.runAndFlush" }
178230
messages.add(key)
179231
}
180232
}

0 commit comments

Comments
 (0)