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." }
}