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

r.0.9.1.source-code.TotalOrderedLog.kt Maven / Gradle / Ivy

The newest version!
package se.wollan.tolr

import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import se.wollan.datascope.DataScope
import se.wollan.time.HybridLogicalClock

/**
 * The main public interface that consuming projects interact with.
 * You must do run database migration before running any of these operations.
 * You also must start by calling [startReplicating] except for pure database operations like
 * [listRecordsByTypeLaterThan] (and similar), [rewriteHistory] and [project].
 */
interface TotalOrderedLog {

    /**
     * Must be called before any other operation.
     * Only first call has any effect.
     *
     * @return true if stated, false if already running
     */
    suspend fun startReplicating(): Boolean

    suspend fun awaitRecord(nodeTimestamp: NodeTimestamp)

    suspend fun triggerReplication()

    suspend fun listRecordsByTypeLaterThan(pattern: RecordTypePattern, since: NodeTimestamp): LogPage

    suspend fun appendRecords(recordData: Iterable>): List
    suspend fun appendRecord(type: RecordType, payload: RecordPayload): LogRecord =
        appendRecords(listOf(type to payload)).single()

    suspend fun handleRPCMessage(inData: String): String

    /**
     * Be very careful with this - you must follow the rules:
     *  - Run the same operation on all nodes, as the changes will not propagate automatically (tolr is an append-only store).
     *  - Do not change timestamp or node-id (this will throw exception) only type and payload.
     *  - One call will only run once on the data currently in the node-db, which may or may not be present on all nodes,
     *    so you must be certain that all nodes already has all records that are going to be modified.
     *    Or make the mapping idempotent and run it multiple times until you are certain every node is update-to-date.
     *
     *  Mapping returning null will not perform an update on that record.
     */
    suspend fun rewriteHistory(mapping: suspend (LogRecord) -> LogRecord?)

    /**
     * Project the log, in order, to a state that is continuously maintained as new records come in.
     *  - If out-of-order (OOO) records are received the projection is automatically restarted in the background,
     * and while that in going on new local and in-order records still updates the active projection until the new
     * candidate projection is swapped in.
     *  - The tip of the projection (last record) will always move forward or sideways timestamp-wise, never backwards.
     * Sideways could happen if a projection is rebuilt due to OOO-record but no new records occurred in the meantime.
     * This is also true across app-restarts, which might mean a startup-time for the projection to get up-to-speed, initially.
     * Save time by starting projections as early as possible after database migration.
     *  - You can be sure that the projection value will have processed all local records, even if it was just appended.
     *  - Exceptions in step function will be rethrown on next value()-call.
     * This will also cause the projection to be aborted. Please restart manually as needed.
     */
    fun  project(
        id: ProjectionId,
        filter: RecordTypePattern,
        initial: T,
        step: suspend (T, LogRecord) -> T
    ): Projection
}

internal class TotalOrderedLogImpl(
    private val recordRepo: LogRecordRepo,
    private val clock: HybridLogicalClock,
    private val replicator: LogReplicator,
    private val rpcServer: RPCServer,
    private val configurationProvider: ConfigurationProvider,
    private val dataScope: DataScope,
    private val projectionFactory: ProjectionFactory
) : TotalOrderedLog {

    @Volatile
    private var started = false
    private val startLock = Mutex()

    override suspend fun startReplicating(): Boolean = startLock.withLock {
        if (started)
            return false

        replicator.startReplicating()
        sanityCheckClock()
        started = true
        return true
    }

    override suspend fun awaitRecord(nodeTimestamp: NodeTimestamp) {
        checkStarted()

        // converting node-id to remote hostname typically only works within backend
        val triggered = replicator.triggerReplicationFor(RemoteHostname(nodeTimestamp.nodeId.value))

        // fallback. local-first clients usually have a single fan-out-node as a remote node, not a timestamping-node
        if (!triggered)
            replicator.triggerReplication()

        val receiveChannel = recordRepo.insertChannel.startReceiving(RecordTypePattern.MATCH_ALL)
        try {
            if (recordRepo.contains(nodeTimestamp))
                return

            for (insertedRecord in receiveChannel) {
                if (insertedRecord.nodeTimestamp == nodeTimestamp)
                    return
            }
        } finally {
            recordRepo.insertChannel.stopReceiving(receiveChannel)
        }
    }

    override suspend fun triggerReplication() {
        checkStarted()
        replicator.triggerReplication()
    }

    override suspend fun listRecordsByTypeLaterThan(pattern: RecordTypePattern, since: NodeTimestamp): LogPage =
        recordRepo.listByTypeLaterThan(pattern.requireNotInternal(), since)

    override suspend fun appendRecords(recordData: Iterable>): List {
        checkStarted()
        val configuration = configurationProvider.getConfiguration()
        val records = recordData.map { (type, payload) ->
            type.requireNotInternal()
            val timestamp = clock.tick()
            LogRecord(timestamp, configuration.localNodeId, type, payload).requireFitInto(configuration.pageSize)
        }

        recordRepo.insert(records)
        projectionFactory.requireTheseRecordsInProjections(records)
        replicator.triggerReplication()

        return records
    }

    override suspend fun handleRPCMessage(inData: String): String {
        checkStarted()
        return rpcServer.handle(inData)
    }

    override suspend fun rewriteHistory(mapping: suspend (LogRecord) -> LogRecord?) {
        dataScope.write {
            recordRepo.listByType(RecordTypePattern.MATCH_ALL).collect { page ->
                val updatedRecords = page.mapNotNullRecords(mapping)
                recordRepo.update(updatedRecords.records)
            }
        }
    }

    override fun  project(
        id: ProjectionId,
        filter: RecordTypePattern,
        initial: T,
        step: suspend (T, LogRecord) -> T
    ): Projection = projectionFactory.createProjection(id, filter, initial, step)

    private suspend fun sanityCheckClock() {
        val now = clock.tick()
        val latest = recordRepo.listLatestPerNodeTimestamps()
        for ((nodeId, ts) in latest) {
            check(now > ts) { "current time $now must be greater than saved record ($ts, $nodeId)" }
        }
    }

    private fun checkStarted() = check(started) { "Please call startReplicating before any other operation." }
}





© 2015 - 2024 Weber Informatics LLC | Privacy Policy