Skip to content

Commit

Permalink
refactor: services should use durations
Browse files Browse the repository at this point in the history
  • Loading branch information
averen committed May 27, 2024
1 parent dc341d7 commit 3bd44a1
Show file tree
Hide file tree
Showing 4 changed files with 77 additions and 58 deletions.
42 changes: 28 additions & 14 deletions src/main/kotlin/com/sandrabot/sandra/entities/Service.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2017-2022 Avery Carroll and Logan Devecka
* Copyright 2017-2024 Avery Carroll and Logan Devecka
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -19,34 +19,45 @@ package com.sandrabot.sandra.entities
import io.ktor.utils.io.CancellationException
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Duration

/**
* Utility class for running periodic tasks throughout the bot.
* Utility class that periodically executes a task using coroutines.
*
* @param interval The interval in seconds between each execution of the task.
* @param initialDelay The initial delay in seconds before the task is executed for the first time.
* The [interval] is the duration between each execution of the task.
* You may delay the first execution of the task by using [initialDelay]. By default, it is the same value as [interval].
*/
abstract class Service(private val interval: Long, private val initialDelay: Long = interval) {
@Suppress("MemberVisibilityCanBePrivate")
abstract class Service(
private val interval: Duration, private val initialDelay: Duration = interval,
) {

private var job: Job? = null

/**
* Used to determine if the service is currently running.
*/
val isActive: Boolean
get() = job?.isActive ?: false

/**
* This method defines the task that should be executed every [interval] by the service.
*/
protected abstract suspend fun execute()

/**
* Starts the service and executes the task every [interval] seconds.
* If the service is already running, this method will do nothing.
* Can be used to modify the startup behavior of the service.
* This method does nothing if the service is already running.
*
* **Warning:** You must always call `super.start()` to actually start the service.
*/
open fun start() {
if (isActive) return
job = serviceScope.launch {
delay(initialDelay.seconds)
delay(initialDelay)
while (isActive) try {
execute()
delay(interval.seconds)
delay(interval)
} catch (_: CancellationException) {
// these can be safely ignored, only occurs when service shuts down
} catch (t: Throwable) {
Expand All @@ -57,15 +68,18 @@ abstract class Service(private val interval: Long, private val initialDelay: Lon
}

/**
* Stops the service and cancels the task.
* Can be used to modify the shutdown behavior of the service.
* This method does nothing if the service is not running.
*
* **Warning:** You must always call `super.shutdown()` to actually stop the service.
*/
open fun shutdown() {
job?.cancel("Service shutdown")
job?.cancel("Service is shutting down")
}

internal companion object {
private companion object {
private val logger = LoggerFactory.getLogger(Service::class.java)
internal val serviceScope = CoroutineScope(Dispatchers.Default)
private val serviceScope = CoroutineScope(Dispatchers.Default)
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2017-2022 Avery Carroll and Logan Devecka
* Copyright 2017-2024 Avery Carroll and Logan Devecka
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -23,7 +23,6 @@ import com.sandrabot.sandra.config.UserConfig
import com.sandrabot.sandra.constants.RedisPrefix
import com.sandrabot.sandra.entities.Service
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import net.dv8tion.jda.api.entities.Guild
Expand All @@ -32,11 +31,18 @@ import net.jodah.expiringmap.ExpirationListener
import net.jodah.expiringmap.ExpirationPolicy
import net.jodah.expiringmap.ExpiringMap
import java.util.concurrent.TimeUnit
import kotlin.reflect.KClass
import kotlin.time.Duration.Companion.seconds

/**
* Stores configuration objects in memory to prevent overhead of recreating objects too often.
* Retrieves, stores, and caches configuration data for users and guilds.
*
* Whenever a configuration is accessed, it is assumed that the data may have been updated by the calling code.
* Every 30 seconds, the recently accessed records will be sent to redis for storage.
*
* Objects expire from cache **1 hour** after they were last accessed to reduce the memory footprint.
*/
class ConfigurationManager(private val sandra: Sandra) : Service(30), ExpirationListener<Long, Configuration> {
class ConfigurationManager(private val sandra: Sandra) : Service(30.seconds), ExpirationListener<Long, Configuration> {

private val json = Json { encodeDefaults = true }
private val accessedKeys = mutableSetOf<Long>()
Expand All @@ -45,26 +51,23 @@ class ConfigurationManager(private val sandra: Sandra) : Service(30), Expiration
.asyncExpirationListener(this).build()

init {
start()
start() // immediately start the service
}

fun countGuilds() = configs.count { it.value is GuildConfig }
fun countUsers() = configs.count { it.value is UserConfig }

override fun shutdown() {
super.shutdown()
runBlocking { execute() }
super.shutdown() // shutdown the service and cancel the job
runBlocking { execute() } // save any leftover keys one last time
}

override suspend fun execute() = synchronized(accessedKeys) {
// create a copy of the list before clearing it and releasing the lock
accessedKeys.toList().also { accessedKeys.clear() }
}.forEach { store(it, configs[it] ?: return@forEach) }

override fun expired(key: Long, value: Configuration) = store(key, value)

private fun store(id: Long, config: Configuration) {
val prefix = if (config is GuildConfig) RedisPrefix.GUILD else RedisPrefix.USER
sandra.redis["$prefix$id"] = json.encodeToString(config)
sandra.redis[prefix(config::class) + "$id"] = json.encodeToString(config)
}

fun getGuild(id: Long) = get<GuildConfig>(id)
Expand All @@ -74,12 +77,17 @@ class ConfigurationManager(private val sandra: Sandra) : Service(30), Expiration
operator fun get(user: User) = getUser(user.idLong)

private inline fun <reified T : Configuration> get(id: Long): T = configs.getOrPut(id) {
val prefix = if (T::class == GuildConfig::class) RedisPrefix.GUILD else RedisPrefix.USER
sandra.redis["$prefix$id"]?.let { json.decodeFromString<T>(it) } ?: when (T::class) {
sandra.redis[prefix(T::class) + "$id"]?.let { json.decodeFromString<T>(it) } ?: when (T::class) {
GuildConfig::class -> GuildConfig(id)
UserConfig::class -> UserConfig(id)
else -> throw AssertionError("Illegal configuration type")
else -> throw AssertionError("Illegal configuration type: ${T::class}")
}
}.also { synchronized(accessedKeys) { accessedKeys += id } } as T

private fun <T : Configuration> prefix(clazz: KClass<T>) = when (clazz) {
GuildConfig::class -> RedisPrefix.GUILD
UserConfig::class -> RedisPrefix.USER
else -> throw AssertionError("Illegal configuration type: $clazz")
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2017-2022 Avery Carroll and Logan Devecka
* Copyright 2017-2024 Avery Carroll and Logan Devecka
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -20,12 +20,14 @@ import com.sandrabot.sandra.Sandra
import com.sandrabot.sandra.constants.Constants
import com.sandrabot.sandra.entities.Service
import com.sandrabot.sandra.entities.Subscription
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes

/**
* Responsible for managing the subscription status of patrons and boosters.
* The subscriptions are refreshed every 30 minutes while the service is running.
*/
class SubscriptionManager(private val sandra: Sandra) : Service(1800, initialDelay = 0) {
class SubscriptionManager(private val sandra: Sandra) : Service(30.minutes, initialDelay = Duration.ZERO) {

private val subscriptions = mutableMapOf<Long, MutableSet<Subscription>>()

Expand Down
49 changes: 22 additions & 27 deletions src/main/kotlin/com/sandrabot/sandra/services/BotListService.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2017-2022 Avery Carroll and Logan Devecka
* Copyright 2017-2024 Avery Carroll and Logan Devecka
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -22,54 +22,49 @@ import com.sandrabot.sandra.entities.Service
import com.sandrabot.sandra.utils.HTTP_CLIENT
import io.ktor.client.request.*
import net.dv8tion.jda.api.JDA
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes

/**
* Periodically posts server count data to various bot lists.
* Periodically posts guild count data to various bot listing sites.
*/
class BotListService(private val sandra: Sandra) : Service(300) {
class BotListService(private val sandra: Sandra) : Service(6.hours, initialDelay = 5.minutes) {

override suspend fun execute() {

// ensure we have an accurate server count
if (sandra.shards.shardCache.any { it.status != JDA.Status.CONNECTED }) return
val guilds = sandra.shards.shardCache.map { it.guildCache.size() }

// https://bots.ondiscord.xyz/bots/302915036492333067
send(onDiscordUrl, sandra.settings.secrets.bodToken, mapOf("guildCount" to guilds.sum()))

// https://discord.boats/bot/302915036492333067
send(boatUrl, sandra.settings.secrets.boatToken, mapOf("server_count" to guilds.sum()))
val guildsPerShard = sandra.shards.shardCache.map { it.guildCache.size() }

// https://top.gg/bot/302915036492333067
send(topGgUrl, sandra.settings.secrets.topGgToken, mapOf("shards" to guilds))
send(TOP_GG, sandra.settings.secrets.topGgToken, mapOf("shards" to guildsPerShard))

// https://botlist.space/bot/302915036492333067
send(spaceUrl, sandra.settings.secrets.spaceToken, mapOf("shards" to guilds))
send(BOTLIST_SPACE, sandra.settings.secrets.spaceToken, mapOf("shards" to guildsPerShard))

guilds.forEachIndexed { i, count ->
guildsPerShard.forEachIndexed { shard, guilds ->
// https://discord.bots.gg/bots/302915036492333067
val botsGgData = mapOf("guildCount" to count, "shardCount" to guilds.size, "shardId" to i)
send(discordBotsUrl, sandra.settings.secrets.dbgToken, botsGgData)
val botsGgData = mapOf("guildCount" to guilds, "shardCount" to guildsPerShard.size, "shardId" to shard)
send(BOTS_GG, sandra.settings.secrets.dbgToken, botsGgData)

// https://discordbotlist.com/bots/302915036492333067
send(dblUrl, sandra.settings.secrets.dblToken, mapOf("shard_id" to i, "guilds" to count))
send(DISCORD_BOT_LIST, sandra.settings.secrets.dblToken, mapOf("shard_id" to shard, "guilds" to guilds))
}

}

private suspend fun send(url: String, token: String, data: Map<String, Any>) =
HTTP_CLIENT.post(url.format(Constants.APPLICATION_ID)) {
header("Authorization", token)
setBody(data)
}
private suspend fun send(
url: String, token: String, data: Map<String, Any>,
) = HTTP_CLIENT.post(url.format(Constants.APPLICATION_ID)) {
header("Authorization", token)
setBody(data)
}

private companion object {
private const val onDiscordUrl = "https://bots.ondiscord.xyz/bot-api/bots/%d/guilds"
private const val boatUrl = "https://discord.boats/api/bot/%d"
private const val topGgUrl = "https://top.gg/api/bots/%d/stats"
private const val spaceUrl = "https://api.botlist.space/v1/bots/%d"
private const val discordBotsUrl = "https://discord.bots.gg/api/v1/bots/%d/stats"
private const val dblUrl = "https://discordbotlist.com/api/bots/%d/stats"
private const val TOP_GG = "https://top.gg/api/bots/%d/stats"
private const val BOTLIST_SPACE = "https://api.botlist.space/v1/bots/%d"
private const val BOTS_GG = "https://discord.bots.gg/api/v1/bots/%d/stats"
private const val DISCORD_BOT_LIST = "https://discordbotlist.com/api/bots/%d/stats"
}

}

0 comments on commit 3bd44a1

Please sign in to comment.