Server: data persistence

pull/699/head
Alinson S. Xavier 5 years ago
parent b0336fb495
commit 7979f74bea

@ -1,6 +1,7 @@
FROM openjdk:8-jre-alpine FROM openjdk:8-jre-alpine
RUN mkdir /app RUN mkdir /app
COPY uhabits-server.jar /app/uhabits-server.jar COPY uhabits-server.jar /app/uhabits-server.jar
ENV LOOP_REPO_PATH /data/
WORKDIR /app WORKDIR /app
CMD ["java", \ CMD ["java", \
"-server", \ "-server", \

@ -24,11 +24,18 @@ import io.ktor.features.*
import io.ktor.jackson.* import io.ktor.jackson.*
import io.ktor.routing.* import io.ktor.routing.*
import org.isoron.uhabits.sync.* import org.isoron.uhabits.sync.*
import org.isoron.uhabits.sync.repository.*
import org.isoron.uhabits.sync.server.*
import java.nio.file.*
fun Application.main() = SyncApplication().apply { main() } fun Application.main() = SyncApplication().apply { main() }
val REPOSITORY_PATH: Path = Paths.get(System.getenv("LOOP_REPO_PATH")!!)
class SyncApplication( class SyncApplication(
val server: AbstractSyncServer = MemorySyncServer(), val server: AbstractSyncServer = RepositorySyncServer(
FileRepository(REPOSITORY_PATH),
),
) { ) {
fun Application.main() { fun Application.main() {
install(DefaultHeaders) install(DefaultHeaders)

@ -0,0 +1,73 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.repository
import org.isoron.uhabits.sync.*
import java.io.*
import java.nio.file.*
class FileRepository(
private val basepath: Path,
) : Repository {
override suspend fun put(key: String, data: SyncData) {
// Create directory
val dataPath = key.toDataPath()
val dataDir = dataPath.toFile()
dataDir.mkdirs()
// Create metadata
val metadataFile = dataPath.resolve("version").toFile()
metadataFile.outputStream().use { outputStream ->
PrintWriter(outputStream).use { printWriter ->
printWriter.print(data.version)
}
}
// Create data file
val dataFile = dataPath.resolve("content").toFile()
dataFile.outputStream().use { outputStream ->
PrintWriter(outputStream).use { printWriter ->
printWriter.print(data.content)
}
}
}
override suspend fun get(key: String): SyncData {
val dataPath = key.toDataPath()
val contentFile = dataPath.resolve("content").toFile()
val versionFile = dataPath.resolve("version").toFile()
if (!contentFile.exists() || !versionFile.exists()) {
throw KeyNotFoundException()
}
val version = versionFile.readText().trim().toLong()
return SyncData(version, contentFile.readText())
}
override suspend fun contains(key: String): Boolean {
val dataPath = key.toDataPath()
val versionFile = dataPath.resolve("version").toFile()
return versionFile.exists()
}
private fun String.toDataPath(): Path {
return basepath.resolve("${this.substring(0..1)}/${this.substring(2..3)}/$this")
}
}

@ -0,0 +1,46 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.sync.repository
import com.sun.org.apache.xpath.internal.operations.*
import org.isoron.uhabits.sync.*
/**
* A class that knows how to store and retrieve a large number of [SyncData] items.
*/
interface Repository {
/**
* Stores a data item, under the provided key. The item can be later retrieved with [get].
* Replaces existing items silently.
*/
suspend fun put(key: String, data: SyncData)
/**
* Retrieves a data item that was previously stored using [put].
* @throws KeyNotFoundException If no such key exists.
*/
suspend fun get(key: String): SyncData
/**
* Returns true if the repository contains a given key.
*/
suspend fun contains(key: String): Boolean
}

@ -17,7 +17,9 @@
* with this program. If not, see <http://www.gnu.org/licenses/>. * with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
package org.isoron.uhabits.sync package org.isoron.uhabits.sync.server
import org.isoron.uhabits.sync.*
interface AbstractSyncServer { interface AbstractSyncServer {
/** /**

@ -17,64 +17,59 @@
* with this program. If not, see <http://www.gnu.org/licenses/>. * with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
package org.isoron.uhabits.sync package org.isoron.uhabits.sync.server
import org.isoron.uhabits.sync.*
import org.isoron.uhabits.sync.repository.*
import java.util.* import java.util.*
import kotlin.streams.* import kotlin.streams.*
/** /**
* An AbstractSyncServer that stores all data in memory. * An AbstractSyncServer that stores all data in a [Repository].
*/ */
class MemorySyncServer : AbstractSyncServer { class RepositorySyncServer(
private val db = mutableMapOf<String, SyncData>() private val repo: Repository,
) : AbstractSyncServer {
override suspend fun register(): String { override suspend fun register(): String {
synchronized(db) { val key = generateKey()
val key = generateKey() repo.put(key, SyncData(0, ""))
db[key] = SyncData(0, "") return key
return key
}
} }
override suspend fun put(key: String, newData: SyncData) { override suspend fun put(key: String, newData: SyncData) {
synchronized(db) { if (!repo.contains(key)) {
if (!db.containsKey(key)) { throw KeyNotFoundException()
throw KeyNotFoundException() }
} val prevData = repo.get(key)
val prevData = db.getValue(key) if (newData.version != prevData.version + 1) {
if (newData.version != prevData.version + 1) { throw EditConflictException()
throw EditConflictException()
}
db[key] = newData
} }
repo.put(key, newData)
} }
override suspend fun getData(key: String): SyncData { override suspend fun getData(key: String): SyncData {
synchronized(db) { if (!repo.contains(key)) {
if (!db.containsKey(key)) { throw KeyNotFoundException()
throw KeyNotFoundException()
}
return db.getValue(key)
} }
return repo.get(key)
} }
override suspend fun getDataVersion(key: String): Long { override suspend fun getDataVersion(key: String): Long {
synchronized(db) { if (!repo.contains(key)) {
if (!db.containsKey(key)) { throw KeyNotFoundException()
throw KeyNotFoundException()
}
return db.getValue(key).version
} }
return repo.get(key).version
} }
private fun generateKey(): String { private suspend fun generateKey(): String {
val chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" val chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
while (true) { while (true) {
val key = Random().ints(64, 0, chars.length) val key = Random().ints(64, 0, chars.length)
.asSequence() .asSequence()
.map(chars::get) .map(chars::get)
.joinToString("") .joinToString("")
if (!db.containsKey(key)) if (!repo.contains(key))
return key return key
} }

@ -20,12 +20,16 @@
package org.isoron.uhabits.sync package org.isoron.uhabits.sync
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.isoron.uhabits.sync.repository.*
import org.isoron.uhabits.sync.server.*
import org.junit.Test import org.junit.Test
import java.nio.file.*
import kotlin.test.* import kotlin.test.*
class MemorySyncServerTest { class RepositorySyncServerTest {
private val server = MemorySyncServer() private val tempdir = Files.createTempDirectory("db")
private val server = RepositorySyncServer(FileRepository(tempdir))
private val key = runBlocking { server.register() } private val key = runBlocking { server.register() }
@Test @Test

@ -20,7 +20,7 @@
package org.isoron.uhabits.sync.app package org.isoron.uhabits.sync.app
import io.ktor.application.* import io.ktor.application.*
import org.isoron.uhabits.sync.* import org.isoron.uhabits.sync.server.*
import org.mockito.Mockito.* import org.mockito.Mockito.*
open class BaseApplicationTest { open class BaseApplicationTest {

@ -0,0 +1,53 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
@file:Suppress("BlockingMethodInNonBlockingContext")
package org.isoron.uhabits.sync.repository
import kotlinx.coroutines.*
import org.hamcrest.CoreMatchers.*
import org.isoron.uhabits.sync.*
import org.junit.*
import org.junit.Assert.*
import java.nio.file.*
class FileRepositoryTest {
@Test
fun testUsage() = runBlocking {
val tempdir = Files.createTempDirectory("db")!!
val repo = FileRepository(tempdir)
val original = SyncData(10, "Hello world")
repo.put("abcdefg", original)
val metaPath = tempdir.resolve("ab/cd/abcdefg/version")
assertTrue("$metaPath should exist", Files.exists(metaPath))
assertEquals("10", metaPath.toFile().readText())
val dataPath = tempdir.resolve("ab/cd/abcdefg/content")
assertTrue("$dataPath should exist", Files.exists(dataPath))
assertEquals("Hello world", dataPath.toFile().readText())
val retrieved = repo.get("abcdefg")
assertThat(retrieved, equalTo(original))
}
}
Loading…
Cancel
Save