All Downloads are FREE. Search and download functionalities are using the official Maven repository.

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}$")
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy