r.0.9.1.source-code.Models.kt Maven / Gradle / Ivy
The newest version!
package se.wollan.tolr
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import se.wollan.time.HLCTimestamp
@Serializable
data class LogRecord(
val timestamp: HLCTimestamp,
val nodeId: NodeId,
val type: RecordType,
val payload: RecordPayload,
@Transient internal val rowId: Long = 0L // > 0 mean the record was just read from db
) {
val nodeTimestamp = NodeTimestamp(timestamp, nodeId) // primary key
internal val size
get() = Long.SIZE_BYTES + nodeId.value.length + type.value.length + payload.value.length
internal fun requireFitInto(pageSize: PageSize): LogRecord {
require(size <= pageSize.value) { "log record size '$size' must be <= page size '$pageSize'" }
return this
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is LogRecord) return false
return timestamp == other.timestamp &&
nodeId == other.nodeId &&
type == other.type &&
payload == other.payload
}
override fun hashCode(): Int {
var result = timestamp.hashCode()
result = 31 * result + nodeId.hashCode()
result = 31 * result + type.hashCode()
result = 31 * result + payload.hashCode()
return result
}
}
@Serializable
internal data class ReplicationBatch(val latestTimestamps: Map, val records: List)
@Serializable
data class NodeTimestamp(val timestamp: HLCTimestamp, val nodeId: NodeId) : Comparable {
/** return a negative number if it's less than other, or a positive number if it's greater than other. */
override operator fun compareTo(other: NodeTimestamp): Int {
val lo = timestamp.compareTo(other.timestamp)
if (lo != 0) return lo
return nodeId.compareTo(other.nodeId) // tie-breaker so TS on different nodes can be totally ordered
}
override fun toString() = "${timestamp}.${nodeId}"
companion object {
val initial = NodeTimestamp(HLCTimestamp.initial, NodeId.zero)
fun fromString(s: String): NodeTimestamp {
val split = s.split('.', limit = 2)
require(split.size == 2) { "invalid node timestamp string format: $s" }
return NodeTimestamp(HLCTimestamp(split[0].toLong()), NodeId(split[1]))
}
}
}
@Serializable
data class LogPage(val next: NodeTimestamp?, val records: List) {
internal suspend fun mapNotNullRecords(mapping: suspend (LogRecord) -> LogRecord?): LogPage =
copy(records = records.mapNotNull { oldRecord ->
val newRecord = mapping(oldRecord) ?: return@mapNotNull null
check(oldRecord.nodeTimestamp == newRecord.nodeTimestamp) { "node timestamp not allowed to change!" }
if (newRecord == oldRecord) null else newRecord
})
internal fun listTimestampsAppendedAfter(rowId: Long): List =
records.filter { it.rowId > rowId }.map { it.nodeTimestamp }
}
/**
* Format examples:
* - orders/123
* - UserCreated
* - 299DC1DF-2332-45C3-BB22-B8555C58B142
*
* Record types internal to TOLR starts with underscore and can not be created by consuming projects.
* Valid characters beyond alphanumeric: ! @ # % & / | ( ) = + ~ _ . : , ; < > -
*/
@JvmInline
@Serializable
value class RecordType(val value: String) {
init {
require(value.isNotBlank()) { "blank record type" }
require(validation.matches(value)) { "invalid record type: $value" }
}
internal fun requireNotInternal(): RecordType {
require(!value.startsWith('_')) { "record type mustn't start with underscore" }
return this
}
override fun toString() = value
fun toPattern() = RecordTypePattern(value)
companion object {
internal const val validChars = "a-zA-Z0-9!@#%&/|)(=+~_.:,;<>-"
private val validation = Regex("^[$validChars]{1,100}$")
}
}
/**
* Same validation as RecordType but can also contain wildcard stars '*' to match zero or more characters.
*/
data class RecordTypePattern(val value: String) {
private val regex: Regex
init {
require(value.isNotBlank()) { "blank record type pattern" }
require(validation.matches(value)) { "invalid record type pattern: $value" }
regex = toRegex()
}
internal fun requireNotInternal(): RecordTypePattern {
require(!value.startsWith('_')) { "record type pattern mustn't start with underscore" }
return this
}
override fun toString() = value
internal fun matches(type: RecordType): Boolean = regex.matches(type.value)
internal fun hasWildcard() = value.contains('*')
internal fun isMatchAll() = value.all { it == '*' }
private fun toRegex(): Regex {
val centerPattern = value.flatMap { c ->
when (c) {
'*' -> listOf('.', '*')
'.' -> listOf('\\', '.')
'+' -> listOf('\\', '+')
'(' -> listOf('\\', '(')
')' -> listOf('\\', ')')
'|' -> listOf('\\', '|')
else -> listOf(c)
}
}.joinToString("")
return Regex("^$centerPattern$")
}
companion object {
// Sqlite GLOB has *?[]^ as special chars, we only support *
private val validation = Regex("^[*${RecordType.validChars}]{1,100}$")
val MATCH_ALL = RecordTypePattern("*")
}
}
@JvmInline
@Serializable
value class RecordPayload(val value: String) {
override fun toString() = value
}
/**
* Identification of a timestamping node.
* Usually the same as hostname for backend, must be stable across node restarts.
* Don't use generated docker hostname/docker id for example, set it explicitly.
* For public frontend clients, this could be some kind of installation id,
* with the same lifetime as local db-data.
*/
@JvmInline
@Serializable
value class NodeId(val value: String) : Comparable {
init {
require(value.length in 1..100) { "bad node id length: ${value.length}" }
require(value.matches(validation)) { "invalid nodeId: $value" }
}
override fun compareTo(other: NodeId): Int = value.compareTo(other.value)
override fun toString() = value
companion object {
internal val validation =
Regex("^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9])\$")
internal val zero = NodeId("0")
}
}
/**
* Remote host to sync with. Could be same as node id for backend, but not necessarily.
* Public frontend clients typically have a single remote hostname pointing to some kind of load balancer.
*/
@JvmInline
@Serializable
value class RemoteHostname(val value: String) {
init {
require(value.matches(NodeId.validation)) { "invalid remote hostname: $value" }
}
override fun toString() = value
}
/**
* The sum of character counts in all log record fields, where the timestamp is counted as eight.
* Therefore, the page size is not the same as number of bytes, which will always be a bit larger,
* depending on if we are talking RAM, json or sqlite.
* All nodes must have the same page size, and it must never decrease, unless done when cluster is down and checked
* that all existing records are within the new page size (select * from tolr_records where size > new_page_size).
*/
@JvmInline
@Serializable
value class PageSize(val value: Int) {
init {
require(value in MIN..MAX) { "page size '$value' must be in range [$MIN, $MAX]" }
}
override fun toString() = value.toString()
companion object {
private const val MIN = 8 + 1 + 1 // to fit the smallest possible record
private const val MAX = 5 * 1024 * 1024 // as the page-records are fully materialized in memory
val min = PageSize(MIN)
val max = PageSize(MAX)
val default = PageSize(512 * 1024)
}
}
/**
* ID of projection, must be unique within application.
*/
@JvmInline
@Serializable
value class ProjectionId(val value: String) {
init {
require(value.matches(validation)) { "invalid projection id: $value" }
}
override fun toString() = value
companion object {
private val validation = Regex("^[_./a-zA-Z0-9]{1,100}$")
}
}