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

games.august.counter.service.DefaultCounterService.kt Maven / Gradle / Ivy

package games.august.counter.service

import games.august.counter.common.model.CounterConfig
import games.august.counter.service.api.CounterApi
import games.august.counter.service.api.model.BatchUpdateCounterRequest
import games.august.counter.service.mapping.toRequest
import games.august.counter.service.model.CounterUpdate
import games.august.counter.service.model.FlushFailureException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import kotlin.jvm.Throws
import kotlin.math.pow
import kotlin.time.Duration.Companion.seconds

internal class DefaultCounterService(
    private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
    private val config: CounterConfig,
    private val counterApi: CounterApi,
    private val onPeriodicFlushFailure: (FlushFailureException) -> Unit = {},
) : CounterService {
    private val queuedUpdates = ConcurrentHashMap>()
    private val queuedItemCount = AtomicInteger(0)
    private var paused: Boolean = false

    private var periodicQueuedItemCountUpdateJob: Job? = null
    private var flushJob: Job? = null

    override fun start() {
        periodicQueuedItemCountUpdateJob =
            scope.launch {
                // Periodically reset queuedItemCount in case of inconsistencies due to high concurrency
                while (isActive) {
                    queuedItemCount.updateAndGet { queuedUpdates.size } // updates.size performs a full sweep to count
                    delay(60.seconds)
                }
            }
        flushJob =
            scope.launch {
                delay(config.flushConfig.cooldown)
                while (isActive) {
                    if (!paused) {
                        try {
                            flush()
                        } catch (e: FlushFailureException) {
                            onPeriodicFlushFailure(e)
                        }
                    }
                    delay(config.flushConfig.cooldown)
                }
            }
    }

    override fun resume() {
        paused = false
    }

    override fun pause() {
        paused = true
    }

    @Throws(FlushFailureException::class)
    override suspend fun shutdown(flushPendingUpdates: Boolean) {
        if (!flushPendingUpdates) {
            periodicQueuedItemCountUpdateJob?.cancel()
            flushJob?.cancel()
            scope.cancel()
            return
        }
        var batch: List = computeBatch()
        while (batch.isNotEmpty()) {
            flush(batch)
            batch = computeBatch()
        }
        periodicQueuedItemCountUpdateJob?.cancelAndJoin()
        flushJob?.cancelAndJoin()
    }

    override fun updateCounter(update: CounterUpdate): Boolean {
        periodicQueuedItemCountUpdateJob ?: errorNotStarted()
        flushJob ?: errorNotStarted()
        val size = queuedItemCount.get()
        if (size >= config.flushConfig.maxBufferSize) return false
        queuedUpdates.compute(update.tag) { tag, previousUpdates ->
            queuedItemCount.incrementAndGet()
            previousUpdates?.plus(update) ?: listOf(update)
        }
        return true
    }

    override fun batchUpdateCounter(updates: List): Boolean {
        periodicQueuedItemCountUpdateJob ?: errorNotStarted()
        flushJob ?: errorNotStarted()
        val size = queuedItemCount.get()
        if (size >= config.flushConfig.maxBufferSize) return false
        for ((tag, updates) in updates.groupBy { it.tag }) {
            queuedUpdates.compute(tag) { tag, previousUpdates ->
                queuedItemCount.updateAndGet { it + updates.size }
                previousUpdates?.plus(updates) ?: updates
            }
        }
        return true
    }

    private fun errorNotStarted(): Nothing = throw IllegalStateException("CounterService.start() must be called before updating counters")

    @Throws(FlushFailureException::class)
    private suspend fun flush(
        batch: List = computeBatch(),
        failureCount: Int = 0,
    ) {
        if (batch.isEmpty()) return
        counterApi
            .batchUpdateCounters(
                apiKey = config.apiKey,
                batchUpdateCounterRequest =
                    BatchUpdateCounterRequest(
                        updates = batch.map(CounterUpdate::toRequest),
                    ),
            ).onFailure { e ->
                if (failureCount < config.flushErrorHandling.maxFailureRetries) {
                    val jitter = config.flushErrorHandling.getBackoffJitter()
                    val backoff =
                        (2.0.pow(failureCount.toDouble() + 1.0) + jitter)
                            .seconds
                            .coerceIn(
                                minimumValue = config.flushErrorHandling.minBackoff,
                                maximumValue = config.flushErrorHandling.maxBackoff,
                            )
                    delay(backoff)
                    flush(batch, failureCount + 1)
                } else {
                    batchUpdateCounter(batch) // re-add these updates to be tried again later at some point
                    throw FlushFailureException(e, "Failed to flush batch after $failureCount attempts")
                }
            }
    }

    private fun computeBatch(): List {
        val maxBatchSize = config.flushConfig.maxBatchSize
        val batch = mutableListOf()
        var added = 0
        // Shuffle in case we can't keep up with counter updates incoming. We don't want to starve the service of
        // updates for tags which are less fortunate when it comes to key priority in [ConcurrentHashMap]
        val keys = queuedUpdates.keys().toList().shuffled()
        for (key in keys) {
            val remainingBatchSize = maxBatchSize - batch.size
            queuedUpdates.compute(key) { tag, previousUpdates ->
                previousUpdates ?: return@compute null
                when {
                    previousUpdates.size > remainingBatchSize -> {
                        batch.addAll(previousUpdates.take(remainingBatchSize))
                        added += remainingBatchSize
                        previousUpdates.subList(remainingBatchSize, previousUpdates.size)
                    }
                    else -> {
                        batch.addAll(previousUpdates)
                        added += previousUpdates.size
                        emptyList()
                    }
                }
            }
            if (batch.size >= maxBatchSize) {
                break
            }
        }
        queuedItemCount.updateAndGet { it - added }
        return batch
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy