From 2f0ba46f2169e6288daa1d747535777e4eb5ddb3 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Sun, 25 Jan 2026 23:25:32 +0100 Subject: [PATCH 1/4] feat: add player hearbeats --- common/build.gradle.kts | 2 +- .../presence/GrpcPlayerPresenceClient.kt | 25 ++++++++ .../kotlin/gg/grounds/GroundsPluginPlayer.kt | 5 ++ .../presence/PlayerHeartbeatScheduler.kt | 59 +++++++++++++++++++ .../grounds/presence/PlayerPresenceService.kt | 11 ++++ 5 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 velocity/src/main/kotlin/gg/grounds/presence/PlayerHeartbeatScheduler.kt diff --git a/common/build.gradle.kts b/common/build.gradle.kts index cce2133..1bc3fdd 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -10,4 +10,4 @@ repositories { } } -dependencies { protobuf("gg.grounds:library-grpc-contracts-player:0.1.0") } +dependencies { protobuf("gg.grounds:library-grpc-contracts-player:feat-player-heartbeat-SNAPSHOT") } diff --git a/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt b/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt index 5a34d1e..d42e656 100644 --- a/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt +++ b/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt @@ -1,5 +1,7 @@ package gg.grounds.player.presence +import gg.grounds.grpc.player.PlayerHeartbeatBatchReply +import gg.grounds.grpc.player.PlayerHeartbeatBatchRequest import gg.grounds.grpc.player.PlayerLoginRequest import gg.grounds.grpc.player.PlayerLogoutReply import gg.grounds.grpc.player.PlayerLogoutRequest @@ -49,6 +51,22 @@ private constructor( } } + fun heartbeatBatch(playerIds: Collection): PlayerHeartbeatBatchReply { + return try { + val request = + PlayerHeartbeatBatchRequest.newBuilder() + .addAllPlayerIds(playerIds.map { it.toString() }) + .build() + stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .playerHeartbeatBatch(request) + } catch (e: StatusRuntimeException) { + errorHeartbeatBatchReply(e.status.toString()) + } catch (e: RuntimeException) { + errorHeartbeatBatchReply(e.message ?: e::class.java.name) + } + } + override fun close() { channel.shutdown() try { @@ -74,6 +92,13 @@ private constructor( private fun errorLogoutReply(message: String): PlayerLogoutReply = PlayerLogoutReply.newBuilder().setRemoved(false).setMessage(message).build() + private fun errorHeartbeatBatchReply(message: String): PlayerHeartbeatBatchReply = + PlayerHeartbeatBatchReply.newBuilder() + .setUpdated(0) + .setMissing(0) + .setMessage(message) + .build() + private fun isServiceUnavailable(status: Status.Code): Boolean = status == Status.Code.UNAVAILABLE || status == Status.Code.DEADLINE_EXCEEDED diff --git a/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt index e5f3bab..c56e123 100644 --- a/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt +++ b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt @@ -9,6 +9,7 @@ import com.velocitypowered.api.plugin.annotation.DataDirectory import com.velocitypowered.api.proxy.ProxyServer import gg.grounds.config.MessagesConfigLoader import gg.grounds.listener.PlayerConnectionListener +import gg.grounds.presence.PlayerHeartbeatScheduler import gg.grounds.presence.PlayerPresenceService import io.grpc.LoadBalancerRegistry import io.grpc.NameResolverRegistry @@ -33,6 +34,8 @@ constructor( @param:DataDirectory private val dataDirectory: Path, ) { private val playerPresenceService = PlayerPresenceService() + private val heartbeatScheduler = + PlayerHeartbeatScheduler(this, proxy, logger, playerPresenceService) init { logger.info("Initialized plugin (plugin=plugin-player, version={})", BuildInfo.VERSION) @@ -55,11 +58,13 @@ constructor( ), ) + heartbeatScheduler.start() logger.info("Configured player presence gRPC client (target={})", target) } @Subscribe fun onShutdown(event: ProxyShutdownEvent) { + heartbeatScheduler.stop() playerPresenceService.close() } diff --git a/velocity/src/main/kotlin/gg/grounds/presence/PlayerHeartbeatScheduler.kt b/velocity/src/main/kotlin/gg/grounds/presence/PlayerHeartbeatScheduler.kt new file mode 100644 index 0000000..e7d44ed --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/presence/PlayerHeartbeatScheduler.kt @@ -0,0 +1,59 @@ +package gg.grounds.presence + +import com.velocitypowered.api.proxy.ProxyServer +import com.velocitypowered.api.scheduler.ScheduledTask +import java.util.concurrent.TimeUnit +import org.slf4j.Logger + +class PlayerHeartbeatScheduler( + private val plugin: Any, + private val proxy: ProxyServer, + private val logger: Logger, + private val presenceService: PlayerPresenceService, +) { + private var heartbeatTask: ScheduledTask? = null + + fun start() { + val heartbeatIntervalSeconds = resolveHeartbeatIntervalSeconds() + heartbeatTask = + proxy.scheduler + .buildTask(plugin, Runnable { sendHeartbeats() }) + .repeat(heartbeatIntervalSeconds, TimeUnit.SECONDS) + .schedule() + logger.info( + "Configured player presence heartbeat task (intervalSeconds={})", + heartbeatIntervalSeconds, + ) + } + + fun stop() { + heartbeatTask?.cancel() + heartbeatTask = null + } + + private fun sendHeartbeats() { + val playerIds = proxy.allPlayers.map { it.uniqueId } + if (playerIds.isEmpty()) { + return + } + + val result = presenceService.heartbeatBatch(playerIds) + if (!result.success) { + logger.error( + "Player session heartbeat batch failed (playerCount={}, error={})", + playerIds.size, + result.message, + ) + } else { + logger.info( + "Player session heartbeat batch completed (playerCount={}, result=success)", + playerIds.size, + ) + } + } + + private fun resolveHeartbeatIntervalSeconds(): Long { + val raw = System.getenv("PLAYER_PRESENCE_HEARTBEAT_SECONDS") ?: return 30 + return raw.toLongOrNull()?.takeIf { it > 0 } ?: 30 + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt b/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt index ba6fa37..12712c8 100644 --- a/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt +++ b/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt @@ -8,6 +8,8 @@ import java.util.UUID class PlayerPresenceService : AutoCloseable { private lateinit var client: GrpcPlayerPresenceClient + data class HeartbeatBatchResult(val success: Boolean, val message: String) + fun configure(target: String) { close() client = GrpcPlayerPresenceClient.create(target) @@ -29,6 +31,15 @@ class PlayerPresenceService : AutoCloseable { } } + fun heartbeatBatch(playerIds: Collection): HeartbeatBatchResult { + return try { + val reply = client.heartbeatBatch(playerIds) + HeartbeatBatchResult(reply.message == "heartbeat accepted", reply.message) + } catch (e: RuntimeException) { + HeartbeatBatchResult(false, e.message ?: e::class.java.name) + } + } + override fun close() { if (this::client.isInitialized) { client.close() From 468fdc6d4d302f225482aefab3e2b9a5dde39249 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 26 Jan 2026 13:37:59 +0100 Subject: [PATCH 2/4] fix: implement proper success handling --- .../gg/grounds/player/presence/GrpcPlayerPresenceClient.kt | 1 + .../main/kotlin/gg/grounds/presence/PlayerHeartbeatScheduler.kt | 2 +- .../main/kotlin/gg/grounds/presence/PlayerPresenceService.kt | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt b/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt index d42e656..680006f 100644 --- a/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt +++ b/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt @@ -96,6 +96,7 @@ private constructor( PlayerHeartbeatBatchReply.newBuilder() .setUpdated(0) .setMissing(0) + .setSuccess(false) .setMessage(message) .build() diff --git a/velocity/src/main/kotlin/gg/grounds/presence/PlayerHeartbeatScheduler.kt b/velocity/src/main/kotlin/gg/grounds/presence/PlayerHeartbeatScheduler.kt index e7d44ed..3116c38 100644 --- a/velocity/src/main/kotlin/gg/grounds/presence/PlayerHeartbeatScheduler.kt +++ b/velocity/src/main/kotlin/gg/grounds/presence/PlayerHeartbeatScheduler.kt @@ -45,7 +45,7 @@ class PlayerHeartbeatScheduler( result.message, ) } else { - logger.info( + logger.debug( "Player session heartbeat batch completed (playerCount={}, result=success)", playerIds.size, ) diff --git a/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt b/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt index 12712c8..a85de39 100644 --- a/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt +++ b/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt @@ -34,7 +34,7 @@ class PlayerPresenceService : AutoCloseable { fun heartbeatBatch(playerIds: Collection): HeartbeatBatchResult { return try { val reply = client.heartbeatBatch(playerIds) - HeartbeatBatchResult(reply.message == "heartbeat accepted", reply.message) + HeartbeatBatchResult(reply.success, reply.message) } catch (e: RuntimeException) { HeartbeatBatchResult(false, e.message ?: e::class.java.name) } From fd77406c65e6250fde75f23d71a16be0da8e6829 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 26 Jan 2026 13:55:11 +0100 Subject: [PATCH 3/4] fix: implement a safeguard to ensure only one hearbeat scheduler is running --- .../main/kotlin/gg/grounds/presence/PlayerHeartbeatScheduler.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/velocity/src/main/kotlin/gg/grounds/presence/PlayerHeartbeatScheduler.kt b/velocity/src/main/kotlin/gg/grounds/presence/PlayerHeartbeatScheduler.kt index 3116c38..8ae7dde 100644 --- a/velocity/src/main/kotlin/gg/grounds/presence/PlayerHeartbeatScheduler.kt +++ b/velocity/src/main/kotlin/gg/grounds/presence/PlayerHeartbeatScheduler.kt @@ -14,6 +14,8 @@ class PlayerHeartbeatScheduler( private var heartbeatTask: ScheduledTask? = null fun start() { + heartbeatTask?.cancel() + heartbeatTask = null val heartbeatIntervalSeconds = resolveHeartbeatIntervalSeconds() heartbeatTask = proxy.scheduler From 945e0679356f52b4ee49de109836ac1bc5389260 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Sun, 8 Mar 2026 18:21:44 +0100 Subject: [PATCH 4/4] feat: clamp heartbeat interval to session TTL and enrich heartbeat result logging --- README.md | 10 +++ velocity/build.gradle.kts | 4 + .../presence/PlayerHeartbeatScheduler.kt | 88 +++++++++++++++++-- .../grounds/presence/PlayerPresenceService.kt | 16 +++- .../presence/PlayerHeartbeatSchedulerTest.kt | 63 +++++++++++++ 5 files changed, 169 insertions(+), 12 deletions(-) create mode 100644 velocity/src/test/kotlin/gg/grounds/presence/PlayerHeartbeatSchedulerTest.kt diff --git a/README.md b/README.md index c23b04b..ccc458b 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,16 @@ The plugin requires the gRPC target to be provided via environment variable: export PLAYER_PRESENCE_GRPC_TARGET="dns:///service-player.api.svc.cluster.local:9000" ``` +Optional heartbeat configuration: + +```bash +export PLAYER_PRESENCE_HEARTBEAT_SECONDS="30" +export PLAYER_SESSIONS_TTL="90s" +``` + +`PLAYER_PRESENCE_HEARTBEAT_SECONDS` is automatically clamped to a safe value based on +`PLAYER_SESSIONS_TTL` (`max = ttl / 3`) to reduce false stale-session cleanup. + Messages are configured in `velocity/src/main/resources/messages.yml` (copied to the plugin data directory on first run). diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index e835e17..9da7e1e 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -5,4 +5,8 @@ dependencies { implementation("tools.jackson.dataformat:jackson-dataformat-yaml:3.0.3") implementation("tools.jackson.module:jackson-module-kotlin:3.0.3") implementation("io.grpc:grpc-netty-shaded:1.78.0") + + testImplementation("org.junit.jupiter:junit-jupiter-api:5.13.4") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.13.4") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.13.4") } diff --git a/velocity/src/main/kotlin/gg/grounds/presence/PlayerHeartbeatScheduler.kt b/velocity/src/main/kotlin/gg/grounds/presence/PlayerHeartbeatScheduler.kt index 8ae7dde..02f8915 100644 --- a/velocity/src/main/kotlin/gg/grounds/presence/PlayerHeartbeatScheduler.kt +++ b/velocity/src/main/kotlin/gg/grounds/presence/PlayerHeartbeatScheduler.kt @@ -3,6 +3,8 @@ package gg.grounds.presence import com.velocitypowered.api.proxy.ProxyServer import com.velocitypowered.api.scheduler.ScheduledTask import java.util.concurrent.TimeUnit +import kotlin.math.max +import kotlin.math.min import org.slf4j.Logger class PlayerHeartbeatScheduler( @@ -16,15 +18,29 @@ class PlayerHeartbeatScheduler( fun start() { heartbeatTask?.cancel() heartbeatTask = null - val heartbeatIntervalSeconds = resolveHeartbeatIntervalSeconds() + val intervalResolution = + resolveHeartbeatIntervalSeconds( + System.getenv(HEARTBEAT_INTERVAL_ENV), + System.getenv(SESSION_TTL_ENV), + ) heartbeatTask = proxy.scheduler .buildTask(plugin, Runnable { sendHeartbeats() }) - .repeat(heartbeatIntervalSeconds, TimeUnit.SECONDS) + .repeat(intervalResolution.effectiveIntervalSeconds, TimeUnit.SECONDS) .schedule() + if (intervalResolution.wasClamped) { + logger.warn( + "Player heartbeat interval clamped (configuredIntervalSeconds={}, effectiveIntervalSeconds={}, sessionTtlSeconds={}, maxIntervalSeconds={})", + intervalResolution.configuredIntervalSeconds, + intervalResolution.effectiveIntervalSeconds, + intervalResolution.sessionTtlSeconds, + intervalResolution.maxIntervalSeconds, + ) + } logger.info( - "Configured player presence heartbeat task (intervalSeconds={})", - heartbeatIntervalSeconds, + "Configured player presence heartbeat task (intervalSeconds={}, sessionTtlSeconds={})", + intervalResolution.effectiveIntervalSeconds, + intervalResolution.sessionTtlSeconds, ) } @@ -42,20 +58,74 @@ class PlayerHeartbeatScheduler( val result = presenceService.heartbeatBatch(playerIds) if (!result.success) { logger.error( - "Player session heartbeat batch failed (playerCount={}, error={})", + "Player session heartbeat batch failed (playerCount={}, updated={}, missing={}, reason={})", playerIds.size, + result.updated, + result.missing, result.message, ) + } else if (result.missing > 0) { + logger.warn( + "Player session heartbeat batch completed with missing sessions (playerCount={}, updated={}, missing={})", + playerIds.size, + result.updated, + result.missing, + ) } else { logger.debug( - "Player session heartbeat batch completed (playerCount={}, result=success)", + "Player session heartbeat batch completed (playerCount={}, updated={}, missing={}, result=success)", playerIds.size, + result.updated, + result.missing, ) } } - private fun resolveHeartbeatIntervalSeconds(): Long { - val raw = System.getenv("PLAYER_PRESENCE_HEARTBEAT_SECONDS") ?: return 30 - return raw.toLongOrNull()?.takeIf { it > 0 } ?: 30 + internal data class HeartbeatIntervalResolution( + val configuredIntervalSeconds: Long, + val effectiveIntervalSeconds: Long, + val sessionTtlSeconds: Long, + val maxIntervalSeconds: Long, + val wasClamped: Boolean, + ) + + companion object { + private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 30L + private const val DEFAULT_SESSION_TTL_SECONDS = 90L + private const val HEARTBEAT_INTERVAL_ENV = "PLAYER_PRESENCE_HEARTBEAT_SECONDS" + private const val SESSION_TTL_ENV = "PLAYER_SESSIONS_TTL" + + internal fun resolveHeartbeatIntervalSeconds( + heartbeatIntervalRaw: String?, + sessionTtlRaw: String?, + ): HeartbeatIntervalResolution { + val configuredInterval = + heartbeatIntervalRaw?.trim()?.toLongOrNull()?.takeIf { it > 0 } + ?: DEFAULT_HEARTBEAT_INTERVAL_SECONDS + val sessionTtlSeconds = + parseSessionTtlSeconds(sessionTtlRaw) ?: DEFAULT_SESSION_TTL_SECONDS + val maxIntervalSeconds = max(1L, sessionTtlSeconds / 3) + val effectiveInterval = min(configuredInterval, maxIntervalSeconds) + return HeartbeatIntervalResolution( + configuredIntervalSeconds = configuredInterval, + effectiveIntervalSeconds = effectiveInterval, + sessionTtlSeconds = sessionTtlSeconds, + maxIntervalSeconds = maxIntervalSeconds, + wasClamped = effectiveInterval != configuredInterval, + ) + } + + private fun parseSessionTtlSeconds(raw: String?): Long? { + val value = raw?.trim()?.lowercase() ?: return null + if (value.isEmpty()) { + return null + } + return when { + value.endsWith("s") -> value.removeSuffix("s").toLongOrNull() + value.endsWith("m") -> value.removeSuffix("m").toLongOrNull()?.times(60) + value.endsWith("h") -> value.removeSuffix("h").toLongOrNull()?.times(3600) + else -> null + }?.takeIf { it > 0 } + } } } diff --git a/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt b/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt index a85de39..2ccd3c1 100644 --- a/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt +++ b/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt @@ -8,7 +8,12 @@ import java.util.UUID class PlayerPresenceService : AutoCloseable { private lateinit var client: GrpcPlayerPresenceClient - data class HeartbeatBatchResult(val success: Boolean, val message: String) + data class HeartbeatBatchResult( + val success: Boolean, + val message: String, + val updated: Int, + val missing: Int, + ) fun configure(target: String) { close() @@ -34,9 +39,14 @@ class PlayerPresenceService : AutoCloseable { fun heartbeatBatch(playerIds: Collection): HeartbeatBatchResult { return try { val reply = client.heartbeatBatch(playerIds) - HeartbeatBatchResult(reply.success, reply.message) + HeartbeatBatchResult(reply.success, reply.message, reply.updated, reply.missing) } catch (e: RuntimeException) { - HeartbeatBatchResult(false, e.message ?: e::class.java.name) + HeartbeatBatchResult( + success = false, + message = e.message ?: e::class.java.name, + updated = 0, + missing = playerIds.size, + ) } } diff --git a/velocity/src/test/kotlin/gg/grounds/presence/PlayerHeartbeatSchedulerTest.kt b/velocity/src/test/kotlin/gg/grounds/presence/PlayerHeartbeatSchedulerTest.kt new file mode 100644 index 0000000..ebb0529 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/presence/PlayerHeartbeatSchedulerTest.kt @@ -0,0 +1,63 @@ +package gg.grounds.presence + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class PlayerHeartbeatSchedulerTest { + @Test + fun resolveHeartbeatIntervalUsesDefaultsWhenUnset() { + val result = PlayerHeartbeatScheduler.resolveHeartbeatIntervalSeconds(null, null) + + assertEquals(30, result.configuredIntervalSeconds) + assertEquals(30, result.effectiveIntervalSeconds) + assertEquals(90, result.sessionTtlSeconds) + assertEquals(30, result.maxIntervalSeconds) + assertFalse(result.wasClamped) + } + + @Test + fun resolveHeartbeatIntervalClampsWhenAboveSafeMaximum() { + val result = PlayerHeartbeatScheduler.resolveHeartbeatIntervalSeconds("60", "90s") + + assertEquals(60, result.configuredIntervalSeconds) + assertEquals(30, result.effectiveIntervalSeconds) + assertEquals(90, result.sessionTtlSeconds) + assertEquals(30, result.maxIntervalSeconds) + assertTrue(result.wasClamped) + } + + @Test + fun resolveHeartbeatIntervalDoesNotClampWhenWithinSafeMaximum() { + val result = PlayerHeartbeatScheduler.resolveHeartbeatIntervalSeconds("20", "90s") + + assertEquals(20, result.configuredIntervalSeconds) + assertEquals(20, result.effectiveIntervalSeconds) + assertEquals(90, result.sessionTtlSeconds) + assertEquals(30, result.maxIntervalSeconds) + assertFalse(result.wasClamped) + } + + @Test + fun resolveHeartbeatIntervalParsesMinuteTtl() { + val result = PlayerHeartbeatScheduler.resolveHeartbeatIntervalSeconds("90", "6m") + + assertEquals(90, result.configuredIntervalSeconds) + assertEquals(90, result.effectiveIntervalSeconds) + assertEquals(360, result.sessionTtlSeconds) + assertEquals(120, result.maxIntervalSeconds) + assertFalse(result.wasClamped) + } + + @Test + fun resolveHeartbeatIntervalFallsBackForInvalidValues() { + val result = PlayerHeartbeatScheduler.resolveHeartbeatIntervalSeconds("-5", "oops") + + assertEquals(30, result.configuredIntervalSeconds) + assertEquals(30, result.effectiveIntervalSeconds) + assertEquals(90, result.sessionTtlSeconds) + assertEquals(30, result.maxIntervalSeconds) + assertFalse(result.wasClamped) + } +}