Cache live Shardok battle reads (#8715)

This commit is contained in:
2026-07-19 09:28:49 -07:00
committed by GitHub
parent 4a33d4b30f
commit 5c1d68960b
7 changed files with 248 additions and 105 deletions
@@ -653,6 +653,7 @@ scala_library(
"HistoryBackendStartupValidation.scala",
"PostgresGameSchemaPool.scala",
"PostgresHistory.scala",
"ShardokLiveBattleCache.scala",
"SqliteHistory.scala",
],
visibility = [
@@ -64,6 +64,12 @@ trait FullGameHistory extends GameHistory {
/** Clear all Shardok results for a battle (used when tutorial battle resets). */
def withResetShardokResults(shardokGameId: ShardokGameId): FullGameHistory
/**
* Releases any in-process Shardok read projection after a battle ends. Durable histories may lazily reload it if a
* later caller needs completed-battle data; histories without a read cache can use this default no-op.
*/
def evictShardokReadCache(shardokGameId: ShardokGameId): Unit = ()
/** Release any held storage resources. In-memory histories can use the default no-op. */
def close(): Unit = ()
@@ -22,21 +22,25 @@ class PostgresHistory private[service] (
private val gameId: GameId,
initialCount: Int,
initialShardokResultCounts: Map[ShardokGameId, Int] = Map.empty,
initialShardokGameStates: Map[ShardokGameId, com.google.protobuf.ByteString] = Map.empty
initialShardokGameStates: Map[ShardokGameId, com.google.protobuf.ByteString] = Map.empty,
initialShardokLiveBattleCaches: Map[ShardokGameId, ShardokLiveBattleCache] = Map.empty
) extends FullGameHistory {
import PostgresHistory.{applier, SnapshotInterval}
private val schemaName: String = PostgresHistory.schemaNameForGame(gameId)
private var cachedCount: Int = initialCount
private val dbLock = new Object
private val actionCache = mutable.Map.empty[Int, ActionResultT]
private val stateCache = mutable.Map.empty[Int, GameState]
private val schemaName: String = PostgresHistory.schemaNameForGame(gameId)
private var cachedCount: Int = initialCount
private val dbLock = new Object
private val actionCache = mutable.Map.empty[Int, ActionResultT]
private val stateCache = mutable.Map.empty[Int, GameState]
// Shardok reconnects need these values while assembling a subscription. Keep them synchronized with committed
// writes so reconnect setup never waits for the shared Postgres connection pool.
private val shardokResultCounts = mutable.Map.from(initialShardokResultCounts)
private val shardokGameStates = mutable.Map.from(initialShardokGameStates)
private var activeConnection = Option.empty[Connection]
private var closed = false
private val shardokResultCounts = mutable.Map.from(initialShardokResultCounts)
private val shardokGameStates = mutable.Map.from(initialShardokGameStates)
// Player views and current commands are a live read projection. Cache misses hydrate one battle from PostgreSQL;
// committed Shardok updates then keep the projection current until battle resolution evicts it.
private val shardokLiveBattleCaches = mutable.Map.from(initialShardokLiveBattleCaches)
private var activeConnection = Option.empty[Connection]
private var closed = false
private def connection: Connection =
activeConnection.getOrElse(
@@ -58,12 +62,14 @@ class PostgresHistory private[service] (
}
}
private def withDbLock[A](f: => A): A =
private def withLock[A](f: => A): A =
dbLock.synchronized {
if closed then throw new EagleInternalException(s"PostgresHistory for game ${gameId.toHexString} is closed")
withConnection(f)
f
}
private def withDbLock[A](f: => A): A = withLock(withConnection(f))
override def count: Int = dbLock.synchronized(cachedCount)
override def emittedTutorialDialogues(factionId: FactionId): Map[String, RoundId] = withDbLock {
@@ -112,6 +118,7 @@ class PostgresHistory private[service] (
override def close(): Unit =
dbLock.synchronized {
closed = true
shardokLiveBattleCaches.clear()
}
private[service] def createSqliteSaveExport(
@@ -226,6 +233,7 @@ class PostgresHistory private[service] (
stateCache.keys.filter(_ > targetActionCount).toVector.foreach(stateCache.remove)
shardokResultCounts.keys.filterNot(validBattleIds).toVector.foreach(shardokResultCounts.remove)
shardokGameStates.keys.filterNot(validBattleIds).toVector.foreach(shardokGameStates.remove)
shardokLiveBattleCaches.keys.filterNot(validBattleIds).toVector.foreach(shardokLiveBattleCaches.remove)
} catch {
case e: Exception =>
connection.rollback()
@@ -362,73 +370,39 @@ class PostgresHistory private[service] (
}
override def shardokCount(shardokGameId: ShardokGameId): Int =
dbLock.synchronized {
if closed then throw new EagleInternalException(s"PostgresHistory for game ${gameId.toHexString} is closed")
withLock {
shardokResultCounts.getOrElse(shardokGameId, 0)
}
override def shardokGameState(shardokGameId: ShardokGameId): com.google.protobuf.ByteString =
dbLock.synchronized {
if closed then throw new EagleInternalException(s"PostgresHistory for game ${gameId.toHexString} is closed")
withLock {
shardokGameStates.getOrElse(shardokGameId, com.google.protobuf.ByteString.EMPTY)
}
override def shardokPlayerCount(shardokGameId: ShardokGameId, factionId: FactionId): Int = withDbLock {
resolveFactionForViews(shardokGameId, factionId).map { fid =>
val stmt = connection.prepareStatement(
"SELECT COUNT(*) FROM shardok_player_results WHERE shardok_game_id = ? AND faction_id = ?"
)
try {
stmt.setString(1, shardokGameId)
stmt.setInt(2, fid)
val rs = stmt.executeQuery()
if rs.next() then rs.getInt(1) else 0
} finally stmt.close()
}
.getOrElse(0)
}
override def shardokPlayerCount(shardokGameId: ShardokGameId, factionId: FactionId): Int =
withLock(shardokLiveBattleCache(shardokGameId).playerResultsFor(factionId).size)
override def shardokPlayerResultsSince(
shardokGameId: ShardokGameId,
factionId: FactionId,
startingCount: Int
): Vector[ShardokActionResultView] = withDbLock {
resolveFactionForViews(shardokGameId, factionId).map { fid =>
val total = shardokPlayerCount(shardokGameId, fid)
if total < startingCount then
throw new EagleInternalException(
s"Wanted $startingCount results but there were only $total for factionId $factionId in game $shardokGameId"
)
val stmt = connection.prepareStatement(
"SELECT payload FROM shardok_player_results WHERE shardok_game_id = ? AND faction_id = ? AND seq >= ? ORDER BY seq"
): Vector[ShardokActionResultView] = withLock {
val results = shardokLiveBattleCache(shardokGameId).playerResultsFor(factionId)
if results.size < startingCount then
throw new EagleInternalException(
s"Wanted $startingCount results but there were only ${results.size} for factionId $factionId in game $shardokGameId"
)
try {
stmt.setString(1, shardokGameId)
stmt.setInt(2, fid)
stmt.setInt(3, startingCount)
val rs = stmt.executeQuery()
val builder = Vector.newBuilder[ShardokActionResultView]
while rs.next() do builder += ShardokActionResultView.parseFrom(rs.getBytes("payload"))
builder.result()
} finally stmt.close()
}
.getOrElse(Vector.empty)
results.drop(startingCount)
}
override def shardokPlayerAvailableCommands(
shardokGameId: ShardokGameId,
factionId: FactionId
): Option[ShardokAvailableCommands] = withDbLock {
val stmt = connection.prepareStatement(
"SELECT payload FROM shardok_player_commands WHERE shardok_game_id = ? AND faction_id = ?"
)
try {
stmt.setString(1, shardokGameId)
stmt.setInt(2, factionId)
val rs = stmt.executeQuery()
if rs.next() then Option(rs.getBytes("payload")).map(ShardokAvailableCommands.parseFrom)
else None
} finally stmt.close()
): Option[ShardokAvailableCommands] =
withLock(shardokLiveBattleCache(shardokGameId).availableCommandsFor(factionId))
override def evictShardokReadCache(shardokGameId: ShardokGameId): Unit = withLock {
shardokLiveBattleCaches.remove(shardokGameId): Unit
}
override def withNewShardokResults(
@@ -442,7 +416,8 @@ class PostgresHistory private[service] (
val originalAutoCommit = connection.getAutoCommit
connection.setAutoCommit(false)
try {
val existingResultCount = shardokCount(shardokGameId)
val existingResultCount = shardokResultCounts.getOrElse(shardokGameId, 0)
val existingLiveCache = shardokLiveBattleCache(shardokGameId)
if newResults.nonEmpty then {
val insertStmt = connection.prepareStatement(
"INSERT INTO shardok_results (shardok_game_id, action_seq, payload) VALUES (?, ?, ?)"
@@ -459,22 +434,8 @@ class PostgresHistory private[service] (
} finally insertStmt.close()
}
val oldFids = {
val stmt =
connection.prepareStatement("SELECT faction_id FROM shardok_player_commands WHERE shardok_game_id = ?")
try {
stmt.setString(1, shardokGameId)
val rs = stmt.executeQuery()
val builder = Set.newBuilder[FactionId]
while rs.next() do builder += rs.getInt("faction_id")
builder.result()
} finally stmt.close()
}
val allFids = (oldFids ++ newPlayerResults.map(_._1).toSet).toVector
val allFids = (existingLiveCache.registeredFactionIds ++ newPlayerResults.map(_._1)).toVector.sorted
val seqStmt = connection.prepareStatement(
"SELECT COUNT(*) FROM shardok_player_results WHERE shardok_game_id = ? AND faction_id = ?"
)
val insertViewStmt = connection.prepareStatement(
"INSERT INTO shardok_player_results (shardok_game_id, faction_id, seq, payload) VALUES (?, ?, ?, ?)"
)
@@ -485,12 +446,7 @@ class PostgresHistory private[service] (
)
try {
allFids.foreach { fid =>
val existingViewCount = {
seqStmt.setString(1, shardokGameId)
seqStmt.setInt(2, fid)
val rs = seqStmt.executeQuery()
if rs.next() then rs.getInt(1) else 0
}
val existingViewCount = existingLiveCache.exactPlayerResults(fid).size
val viewsForFid = newPlayerResults.find(_._1 == fid).fold(Vector.empty)(_._2)
viewsForFid.zipWithIndex.foreach {
case (view, i) =>
@@ -511,7 +467,6 @@ class PostgresHistory private[service] (
insertViewStmt.executeBatch(): Unit
upsertCmdStmt.executeBatch(): Unit
} finally {
seqStmt.close()
insertViewStmt.close()
upsertCmdStmt.close()
}
@@ -531,6 +486,7 @@ class PostgresHistory private[service] (
connection.commit()
shardokResultCounts(shardokGameId) = existingResultCount + newResults.size
shardokGameStates(shardokGameId) = com.google.protobuf.ByteString.copyFrom(currentGameState)
shardokLiveBattleCaches(shardokGameId) = existingLiveCache.appended(newPlayerResults, availableCommands)
} catch {
case e: Exception =>
connection.rollback()
@@ -559,6 +515,7 @@ class PostgresHistory private[service] (
connection.commit()
shardokResultCounts.remove(shardokGameId): Unit
shardokGameStates.remove(shardokGameId): Unit
shardokLiveBattleCaches.remove(shardokGameId): Unit
} catch {
case e: Exception =>
connection.rollback()
@@ -583,6 +540,7 @@ class PostgresHistory private[service] (
shardokExport.states.foreach { state =>
shardokGameStates(state.shardokGameId) = com.google.protobuf.ByteString.copyFrom(state.gameState)
}
shardokLiveBattleCaches.clear()
} catch {
case e: Exception =>
connection.rollback()
@@ -663,28 +621,11 @@ class PostgresHistory private[service] (
builder.result()
}
private def resolveFactionForViews(shardokGameId: ShardokGameId, factionId: FactionId): Option[FactionId] = {
val stmt = connection.prepareStatement(
"SELECT 1 FROM shardok_player_commands WHERE shardok_game_id = ? AND faction_id = ? LIMIT 1"
private def shardokLiveBattleCache(shardokGameId: ShardokGameId): ShardokLiveBattleCache =
shardokLiveBattleCaches.getOrElseUpdate(
shardokGameId,
withConnection(PostgresHistory.readShardokLiveBattleCache(connection, shardokGameId))
)
try {
stmt.setString(1, shardokGameId)
stmt.setInt(2, factionId)
val rs = stmt.executeQuery()
if rs.next() then Some(factionId)
else {
val fallback = connection.prepareStatement(
"SELECT 1 FROM shardok_player_commands WHERE shardok_game_id = ? AND faction_id = -1 LIMIT 1"
)
try {
fallback.setString(1, shardokGameId)
val rs2 = fallback.executeQuery()
if rs2.next() then Some(-1) else None
} finally fallback.close()
}
} finally stmt.close()
end try
}
private def deleteWhereAtOrAfter(table: String, column: String, targetActionCount: Int): Unit = {
val stmt = connection.prepareStatement(s"DELETE FROM $table WHERE $column >= ?")
@@ -874,6 +815,43 @@ object PostgresHistory {
} finally stmt.close()
}
private[service] def readShardokLiveBattleCache(
connection: Connection,
shardokGameId: ShardokGameId
): ShardokLiveBattleCache = {
val sql =
"""SELECT 0 AS row_type, faction_id, seq, payload
|FROM shardok_player_results
|WHERE shardok_game_id = ?
|UNION ALL
|SELECT 1 AS row_type, faction_id, NULL AS seq, payload
|FROM shardok_player_commands
|WHERE shardok_game_id = ?
|ORDER BY faction_id, row_type, seq""".stripMargin
val stmt = connection.prepareStatement(sql)
try {
stmt.setString(1, shardokGameId)
stmt.setString(2, shardokGameId)
val rs = stmt.executeQuery()
val resultsByFaction = mutable.Map.empty[FactionId, Vector[ShardokActionResultView]]
val commandsByFaction = Map.newBuilder[FactionId, Option[ShardokAvailableCommands]]
while rs.next() do {
val factionId = rs.getInt("faction_id")
rs.getInt("row_type") match {
case 0 =>
val result = ShardokActionResultView.parseFrom(rs.getBytes("payload"))
resultsByFaction(factionId) = resultsByFaction.getOrElse(factionId, Vector.empty) :+ result
case 1 =>
commandsByFaction += factionId -> Option(rs.getBytes("payload")).map(ShardokAvailableCommands.parseFrom)
case rowType =>
throw new EagleInternalException(s"Unexpected Shardok live cache row type $rowType")
}
}
ShardokLiveBattleCache(resultsByFaction.toMap, commandsByFaction.result())
} finally stmt.close()
end try
}
private[service] def schemaNameForGame(gameId: GameId): String = {
val schema = "eagle_game_" + java.lang.Long.toHexString(gameId)
if !schema.matches("[a-z][a-z0-9_]*") then
@@ -0,0 +1,50 @@
package net.eagle0.eagle.service
import net.eagle0.eagle.FactionId
import net.eagle0.shardok.api.action_result_view.ActionResultView as ShardokActionResultView
import net.eagle0.shardok.api.command_descriptor.AvailableCommands as ShardokAvailableCommands
/**
* In-process read projection for one active Shardok battle. PostgreSQL remains the durable source of truth, while this
* projection avoids rereading player views and current commands on every client update.
*
* A key in `availableCommandsByFaction` records that the faction participates in this battle even when its value is
* `None`. That distinction preserves the persisted `NULL` command row and the `-1` spectator fallback behavior.
*/
private[service] final case class ShardokLiveBattleCache(
playerResultsByFaction: Map[FactionId, Vector[ShardokActionResultView]],
availableCommandsByFaction: Map[FactionId, Option[ShardokAvailableCommands]]
) {
def registeredFactionIds: Set[FactionId] = availableCommandsByFaction.keySet
def exactPlayerResults(factionId: FactionId): Vector[ShardokActionResultView] =
playerResultsByFaction.getOrElse(factionId, Vector.empty)
def playerResultsFor(factionId: FactionId): Vector[ShardokActionResultView] =
resolvedFactionId(factionId).fold(Vector.empty)(exactPlayerResults)
def availableCommandsFor(factionId: FactionId): Option[ShardokAvailableCommands] =
availableCommandsByFaction.get(factionId).flatten
def appended(
newPlayerResults: Vector[(FactionId, Vector[ShardokActionResultView])],
availableCommands: Vector[(FactionId, Option[ShardokAvailableCommands])]
): ShardokLiveBattleCache = {
val allFactionIds = registeredFactionIds ++ newPlayerResults.map(_._1)
val updatedResults = allFactionIds.foldLeft(playerResultsByFaction) { (results, factionId) =>
val appended = newPlayerResults.find(_._1 == factionId).fold(Vector.empty)(_._2)
results.updated(factionId, results.getOrElse(factionId, Vector.empty) ++ appended)
}
val updatedCommands = allFactionIds.foldLeft(availableCommandsByFaction) { (commands, factionId) =>
commands.updated(factionId, availableCommands.find(_._1 == factionId).flatMap(_._2))
}
copy(
playerResultsByFaction = updatedResults,
availableCommandsByFaction = updatedCommands
)
}
private def resolvedFactionId(factionId: FactionId): Option[FactionId] =
if availableCommandsByFaction.contains(factionId) then Some(factionId)
else Option.when(availableCommandsByFaction.contains(-1))(-1)
}
@@ -752,6 +752,9 @@ final case class GameController(
.toSet
val cleanedProgress = updatedProgress.filter { case (id, _) => remainingBattleIds.contains(id) }
if !remainingBattleIds.contains(shardokGameId) then
result.gameController.fullHistory.evictShardokReadCache(shardokGameId)
// Reset gated round when all battles have ended so the next battle starts at day 0
val cleanedLastRevealedRound =
if cleanedProgress.isEmpty then 0
@@ -38,6 +38,8 @@ scala_test(
timeout = "short",
srcs = ["PostgresHistorySchemaBatchTest.scala"],
deps = [
"//src/main/protobuf/net/eagle0/shardok/api:action_result_view_scala_proto",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/service:changed_province_table_writer",
"//src/main/scala/net/eagle0/eagle/service:sqlite_history",
@@ -3,6 +3,8 @@ package net.eagle0.eagle.service
import java.sql.{Connection, PreparedStatement, ResultSet, Statement}
import com.google.protobuf.ByteString
import net.eagle0.shardok.api.action_result_view.ActionResultView as ShardokActionResultView
import net.eagle0.shardok.api.command_descriptor.AvailableCommands as ShardokAvailableCommands
import org.scalamock.scalatest.MockFactory
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
@@ -231,6 +233,69 @@ class PostgresHistorySchemaBatchTest extends AnyFlatSpec with Matchers with Mock
history.shardokGameState("unknown") shouldBe ByteString.EMPTY
}
"Shardok live battle cache" should "serve player results with spectator fallback and exact commands" in {
val firstResult = ShardokActionResultView()
val secondResult = ShardokActionResultView()
val availableCommand = ShardokAvailableCommands()
val cache = ShardokLiveBattleCache(
playerResultsByFaction = Map(
1 -> Vector(firstResult, secondResult),
-1 -> Vector(firstResult)
),
availableCommandsByFaction = Map(
1 -> Some(availableCommand),
-1 -> None
)
)
cache.playerResultsFor(1) should have size 2
cache.playerResultsFor(999) should have size 1
cache.availableCommandsFor(1) shouldBe Some(availableCommand)
cache.availableCommandsFor(999) shouldBe None
cache.registeredFactionIds shouldBe Set(-1, 1)
}
it should "append views and replace commands after a committed update" in {
val existingResult = ShardokActionResultView()
val appendedResult = ShardokActionResultView()
val spectatorResult = ShardokActionResultView()
val availableCommand = ShardokAvailableCommands()
val cache = ShardokLiveBattleCache(
playerResultsByFaction = Map(1 -> Vector(existingResult), -1 -> Vector(spectatorResult)),
availableCommandsByFaction = Map(1 -> Some(availableCommand), -1 -> None)
)
val updated = cache.appended(
newPlayerResults = Vector(1 -> Vector(appendedResult)),
availableCommands = Vector(1 -> None)
)
updated.exactPlayerResults(1) shouldBe Vector(existingResult, appendedResult)
updated.exactPlayerResults(-1) shouldBe Vector(spectatorResult)
updated.availableCommandsFor(1) shouldBe None
updated.registeredFactionIds shouldBe Set(-1, 1)
}
it should "let PostgresHistory answer repeated reads without a database connection" in {
val result = ShardokActionResultView()
val availableCommand = ShardokAvailableCommands()
val history = new PostgresHistory(
gameId = 0x1234L,
initialCount = 1,
initialShardokLiveBattleCaches = Map(
"battle" -> ShardokLiveBattleCache(
playerResultsByFaction = Map(1 -> Vector(result)),
availableCommandsByFaction = Map(1 -> Some(availableCommand))
)
)
)
history.shardokPlayerCount("battle", 1) shouldBe 1
history.shardokPlayerResultsSince("battle", 1, 0) shouldBe Vector(result)
history.shardokPlayerResultsSince("battle", 1, 1) shouldBe Vector.empty
history.shardokPlayerAvailableCommands("battle", 1) shouldBe Some(availableCommand)
}
"readShardokSubscriptionState" should "load counts and states in one query" in {
val connection = mock[Connection]
val statement = mock[Statement]
@@ -256,4 +321,42 @@ class PostgresHistorySchemaBatchTest extends AnyFlatSpec with Matchers with Mock
counts shouldBe Map("battle-with-state" -> 3, "battle-without-state" -> 2)
states shouldBe Map("battle-with-state" -> ByteString.copyFrom(Array[Byte](1, 2, 3)))
}
"readShardokLiveBattleCache" should "hydrate results and nullable commands in one query" in {
val connection = mock[Connection]
val statement = mock[PreparedStatement]
val resultSet = mock[ResultSet]
val result = ShardokActionResultView()
val availableCommand = ShardokAvailableCommands()
(connection.prepareStatement(_: String)).expects(*).returning(statement).once(): Unit
statement.setString.expects(1, "battle").once(): Unit
statement.setString.expects(2, "battle").once(): Unit
(() => statement.executeQuery()).expects().returning(resultSet).once(): Unit
inSequence {
(() => resultSet.next()).expects().returning(true).once(): Unit
(resultSet.getInt(_: String)).expects("faction_id").returning(1).once(): Unit
(resultSet.getInt(_: String)).expects("row_type").returning(0).once(): Unit
(resultSet.getBytes(_: String)).expects("payload").returning(result.toByteArray).once(): Unit
(() => resultSet.next()).expects().returning(true).once(): Unit
(resultSet.getInt(_: String)).expects("faction_id").returning(-1).once(): Unit
(resultSet.getInt(_: String)).expects("row_type").returning(1).once(): Unit
(resultSet.getBytes(_: String)).expects("payload").returning(null).once(): Unit
(() => resultSet.next()).expects().returning(true).once(): Unit
(resultSet.getInt(_: String)).expects("faction_id").returning(1).once(): Unit
(resultSet.getInt(_: String)).expects("row_type").returning(1).once(): Unit
(resultSet.getBytes(_: String)).expects("payload").returning(availableCommand.toByteArray).once(): Unit
(() => resultSet.next()).expects().returning(false).once(): Unit
}
(() => statement.close()).expects().once(): Unit
val cache = PostgresHistory.readShardokLiveBattleCache(connection, "battle")
cache.exactPlayerResults(1) shouldBe Vector(result)
cache.availableCommandsFor(1) shouldBe Some(availableCommand)
cache.registeredFactionIds shouldBe Set(-1, 1)
}
}