Skip to content

Commit d029ea9

Browse files
authored
Merge pull request #338 from modelix/bugfix/ignite-nested-transactions
fix(model-server): writing to a branch using the v1 API failed
2 parents fb1fc1f + 1c516bb commit d029ea9

File tree

9 files changed

+376
-108
lines changed

9 files changed

+376
-108
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
.gradle/
22
/build/
33
/*/build/
4+
/*/ignite/
45
.DS_Store
56
.gradletasknamecache
67
.idea/

commitlint.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,7 @@ module.exports = {
3232
// No need to restrict the body line length. That only gives issues with URLs etc.
3333
"body-max-line-length": [0, 'always']
3434
},
35+
ignores: [
36+
(message) => message.includes('skip-lint')
37+
],
3538
};

model-server/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ dependencies {
6262
testImplementation(libs.junit)
6363
testImplementation(libs.cucumber.java)
6464
testImplementation(libs.ktor.server.test.host)
65+
testImplementation(libs.kotlin.coroutines.test)
6566
testImplementation(kotlin("test"))
6667
testImplementation(project(":modelql-untyped"))
6768
}

model-server/src/main/kotlin/org/modelix/model/server/Main.kt

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,11 @@ import org.modelix.model.server.store.IStoreClient
5656
import org.modelix.model.server.store.IgniteStoreClient
5757
import org.modelix.model.server.store.InMemoryStoreClient
5858
import org.modelix.model.server.store.LocalModelClient
59+
import org.modelix.model.server.store.loadDump
60+
import org.modelix.model.server.store.writeDump
5961
import org.modelix.model.server.templates.PageWithMenuBar
6062
import org.slf4j.LoggerFactory
6163
import java.io.File
62-
import java.io.FileReader
63-
import java.io.FileWriter
6464
import java.io.IOException
6565
import java.nio.charset.StandardCharsets
6666
import java.time.Duration
@@ -111,18 +111,16 @@ object Main {
111111
}
112112
storeClient = InMemoryStoreClient()
113113
if (cmdLineArgs.dumpInName != null) {
114-
val file = File(cmdLineArgs.dumpInName)
115-
val keys = storeClient.load(FileReader(file))
116-
println(
117-
"Values loaded from " + file.absolutePath + " (" + keys + ")",
118-
)
114+
val file = File(cmdLineArgs.dumpInName!!)
115+
val keys = storeClient.loadDump(file)
116+
println("Values loaded from " + file.absolutePath + " (" + keys + ")")
119117
}
120118
if (cmdLineArgs.dumpOutName != null) {
121119
Runtime.getRuntime()
122120
.addShutdownHook(
123121
DumpOutThread(
124122
storeClient,
125-
cmdLineArgs.dumpOutName,
123+
cmdLineArgs.dumpOutName ?: "dump",
126124
),
127125
)
128126
}
@@ -243,24 +241,14 @@ object Main {
243241
}
244242
}
245243

246-
private class DumpOutThread internal constructor(inMemoryStoreClient: InMemoryStoreClient, dumpName: String?) :
244+
private class DumpOutThread internal constructor(storeClient: IStoreClient, dumpName: String) :
247245
Thread(
248246
Runnable {
249-
var fw: FileWriter? = null
250247
try {
251-
fw = FileWriter(File(dumpName))
252-
inMemoryStoreClient.dump(fw!!)
248+
storeClient.writeDump(File(dumpName))
253249
println("[Saved memory store into $dumpName]")
254250
} catch (e: IOException) {
255251
e.printStackTrace()
256-
} finally {
257-
if (fw != null) {
258-
try {
259-
fw!!.close()
260-
} catch (e: IOException) {
261-
e.printStackTrace()
262-
}
263-
}
264252
}
265253
},
266254
)

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

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,15 @@ import kotlinx.coroutines.coroutineScope
1919
import kotlinx.coroutines.launch
2020
import kotlinx.coroutines.withTimeoutOrNull
2121
import org.modelix.model.IKeyListener
22+
import java.io.File
23+
import java.io.IOException
2224
import kotlin.time.Duration.Companion.seconds
2325

24-
interface IStoreClient {
26+
interface IStoreClient : AutoCloseable {
2527
operator fun get(key: String): String?
2628
fun getAll(keys: List<String>): List<String?>
2729
fun getAll(keys: Set<String>): Map<String, String?>
30+
fun getAll(): Map<String, String?>
2831
fun put(key: String, value: String?, silent: Boolean = false)
2932
fun putAll(entries: Map<String, String?>, silent: Boolean = false)
3033
fun listen(key: String, listener: IKeyListener)
@@ -79,3 +82,30 @@ suspend fun pollEntry(storeClient: IStoreClient, key: String, lastKnownValue: St
7982
}
8083
return result
8184
}
85+
86+
fun IStoreClient.loadDump(file: File): Int {
87+
var n = 0
88+
file.useLines { lines ->
89+
val entries = lines.associate { line ->
90+
val parts = line.split("#".toRegex(), limit = 2)
91+
n++
92+
parts[0] to parts[1]
93+
}
94+
putAll(entries, silent = true)
95+
}
96+
return n
97+
}
98+
99+
@Synchronized
100+
@Throws(IOException::class)
101+
fun IStoreClient.writeDump(file: File) {
102+
file.writer().use { writer ->
103+
for ((key, value) in getAll()) {
104+
if (value == null) continue
105+
writer.append(key)
106+
writer.append("#")
107+
writer.append(value)
108+
writer.append("\n")
109+
}
110+
}
111+
}

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

Lines changed: 109 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,28 @@
1414
*/
1515
package org.modelix.model.server.store
1616

17-
import com.google.common.collect.MultimapBuilder
17+
import mu.KotlinLogging
1818
import org.apache.ignite.Ignite
1919
import org.apache.ignite.IgniteCache
2020
import org.apache.ignite.Ignition
2121
import org.modelix.model.IKeyListener
22+
import org.modelix.model.persistent.HashUtil
2223
import java.io.File
2324
import java.io.FileReader
2425
import java.io.IOException
2526
import java.util.*
26-
import java.util.concurrent.Executors
2727
import java.util.stream.Collectors
2828

29-
class IgniteStoreClient(jdbcConfFile: File?) : IStoreClient {
30-
private val ignite: Ignite
29+
private val LOG = KotlinLogging.logger { }
30+
31+
class IgniteStoreClient(jdbcConfFile: File? = null, inmemory: Boolean = false) : IStoreClient, AutoCloseable {
32+
private val ENTRY_CHANGED_TOPIC = "entryChanged"
33+
private lateinit var ignite: Ignite
3134
private val cache: IgniteCache<String, String?>
32-
private val timer = Executors.newScheduledThreadPool(1)
33-
private val listeners = MultimapBuilder.hashKeys().hashSetValues().build<String, IKeyListener>()
35+
private val changeNotifier = ChangeNotifier(this)
36+
private val pendingChangeMessages = PendingChangeMessages {
37+
ignite.message().send(ENTRY_CHANGED_TOPIC, it)
38+
}
3439

3540
/**
3641
* Istantiate an IgniteStoreClient
@@ -63,11 +68,18 @@ class IgniteStoreClient(jdbcConfFile: File?) : IStoreClient {
6368
)
6469
}
6570
}
66-
ignite = Ignition.start(javaClass.getResource("ignite.xml"))
71+
ignite = Ignition.start(javaClass.getResource(if (inmemory) "ignite-inmemory.xml" else "ignite.xml"))
6772
cache = ignite.getOrCreateCache("model")
6873
// timer.scheduleAtFixedRate(() -> {
6974
// System.out.println("stats: " + cache.metrics());
7075
// }, 10, 10, TimeUnit.SECONDS);
76+
77+
ignite.message().localListen(ENTRY_CHANGED_TOPIC) { nodeId: UUID?, key: Any? ->
78+
if (key is String) {
79+
changeNotifier.notifyListeners(key)
80+
}
81+
true
82+
}
7183
}
7284

7385
override fun get(key: String): String? {
@@ -83,51 +95,38 @@ class IgniteStoreClient(jdbcConfFile: File?) : IStoreClient {
8395
return cache.getAll(keys)
8496
}
8597

98+
override fun getAll(): Map<String, String?> {
99+
return cache.associate { it.key to it.value }
100+
}
101+
86102
override fun put(key: String, value: String?, silent: Boolean) {
87103
putAll(Collections.singletonMap(key, value), silent)
88104
}
89105

90106
override fun putAll(entries: Map<String, String?>, silent: Boolean) {
91107
val deletes = entries.filterValues { it == null }
92108
val puts = entries.filterValues { it != null }
93-
if (deletes.isNotEmpty()) cache.removeAll(deletes.keys)
94-
if (puts.isNotEmpty()) cache.putAll(puts)
95-
if (!silent) {
96-
for ((key, value) in entries) {
97-
ignite.message().send(key, value ?: IKeyListener.NULL_VALUE)
109+
runTransaction {
110+
if (deletes.isNotEmpty()) cache.removeAll(deletes.keys)
111+
if (puts.isNotEmpty()) cache.putAll(puts)
112+
if (!silent) {
113+
for (key in entries.keys) {
114+
if (HashUtil.isSha256(key)) continue
115+
pendingChangeMessages.entryChanged(key)
116+
}
98117
}
99118
}
100119
}
101120

102121
override fun listen(key: String, listener: IKeyListener) {
103-
synchronized(listeners) {
104-
val wasSubscribed = listeners.containsKey(key)
105-
listeners.put(key, listener)
106-
if (!wasSubscribed) {
107-
ignite.message()
108-
.localListen(
109-
key,
110-
) { nodeId: UUID?, value: Any? ->
111-
if (value is String) {
112-
synchronized(listeners) {
113-
for (l in listeners[key].toList()) {
114-
try {
115-
l.changed(key, if (value == IKeyListener.NULL_VALUE) null else value)
116-
} catch (ex: Exception) {
117-
println(ex.message)
118-
ex.printStackTrace()
119-
}
120-
}
121-
}
122-
}
123-
true
124-
}
125-
}
126-
}
122+
// Entries where the key is the SHA hash over the value are not expected to change and listening is unnecessary.
123+
require(!HashUtil.isSha256(key)) { "Listener for $key will never get notified." }
124+
125+
changeNotifier.addListener(key, listener)
127126
}
128127

129128
override fun removeListener(key: String, listener: IKeyListener) {
130-
synchronized(listeners) { listeners.remove(key, listener) }
129+
changeNotifier.removeListener(key, listener)
131130
}
132131

133132
override fun generateId(key: String): Long {
@@ -136,14 +135,83 @@ class IgniteStoreClient(jdbcConfFile: File?) : IStoreClient {
136135

137136
override fun <T> runTransaction(body: () -> T): T {
138137
val transactions = ignite.transactions()
139-
transactions.txStart().use { tx ->
140-
val result = body()
141-
tx.commit()
142-
return result
138+
if (transactions.tx() == null) {
139+
transactions.txStart().use { tx ->
140+
val result = body()
141+
tx.commit()
142+
pendingChangeMessages.flushChangeMessages()
143+
return result
144+
}
145+
} else {
146+
// already in a transaction
147+
return body()
143148
}
144149
}
145150

146151
fun dispose() {
147152
ignite.close()
148153
}
154+
155+
override fun close() {
156+
dispose()
157+
}
158+
}
159+
160+
class PendingChangeMessages(private val notifier: (String) -> Unit) {
161+
private val pendingChangeMessages = Collections.synchronizedSet(HashSet<String>())
162+
163+
@Synchronized
164+
fun flushChangeMessages() {
165+
for (pendingChangeMessage in pendingChangeMessages) {
166+
notifier(pendingChangeMessage)
167+
}
168+
pendingChangeMessages.clear()
169+
}
170+
171+
@Synchronized
172+
fun entryChanged(key: String) {
173+
pendingChangeMessages += key
174+
}
175+
}
176+
177+
class ChangeNotifier(val store: IStoreClient) {
178+
private val changeNotifiers = HashMap<String, EntryChangeNotifier>()
179+
180+
@Synchronized
181+
fun notifyListeners(key: String) {
182+
changeNotifiers[key]?.notifyIfChanged()
183+
}
184+
185+
@Synchronized
186+
fun addListener(key: String, listener: IKeyListener) {
187+
changeNotifiers.getOrPut(key) { EntryChangeNotifier(key) }.listeners.add(listener)
188+
}
189+
190+
@Synchronized
191+
fun removeListener(key: String, listener: IKeyListener) {
192+
val notifier = changeNotifiers[key] ?: return
193+
notifier.listeners.remove(listener)
194+
if (notifier.listeners.isEmpty()) {
195+
changeNotifiers.remove(key)
196+
}
197+
}
198+
199+
private inner class EntryChangeNotifier(val key: String) {
200+
val listeners = HashSet<IKeyListener>()
201+
private var lastNotifiedValue: String? = null
202+
203+
fun notifyIfChanged() {
204+
val value = store.get(key)
205+
if (value == lastNotifiedValue) return
206+
lastNotifiedValue = value
207+
208+
for (listener in listeners) {
209+
try {
210+
listener.changed(key, value)
211+
} catch (ex: Exception) {
212+
LOG.error("Exception in listener of $key", ex)
213+
}
214+
}
215+
}
216+
}
149217
}

0 commit comments

Comments
 (0)