Repair stale generated text on reconnect (#8696)

* Recover missing recruit hero backstories

* Validate completed client text byte counts

* Revert "Recover missing recruit hero backstories"

This reverts commit 9cc349f8e9a477700d188323037bda5c6ebc30ec.
This commit is contained in:
2026-07-18 07:56:09 -07:00
committed by GitHub
parent a59b732b04
commit a919e8edf0
6 changed files with 99 additions and 36 deletions
@@ -1,5 +1,7 @@
package net.eagle0.eagle.client_text
import java.nio.charset.StandardCharsets
import net.eagle0.eagle.{ClientTextId, FactionId}
import net.eagle0.eagle.model.action_result.generated_text_request.GeneratedTextRequestT
@@ -165,21 +167,25 @@ trait ClientTextStore {
* accessibleTo maps. SQLite implementation uses an optimized single query.
*/
def getCompleteTextsAccessibleTo(factionId: FactionId): Vector[CompleteClientText] =
getCompleteTextsAccessibleTo(factionId, knownCompleteTextIds = Set.empty)
getCompleteTextsAccessibleTo(factionId, knownCompleteTextByteCounts = Map.empty)
/**
* Like getCompleteTextsAccessibleTo, but excludes completed text IDs the client already has. Database-backed stores
* can use this to avoid reading and serializing cached text on reconnect.
* Like getCompleteTextsAccessibleTo, but excludes completed text the client already has with the same UTF-8 byte
* count. Comparing lengths prevents a legacy client cache containing an empty or stale completion from permanently
* suppressing the server's repaired text on reconnect. Database-backed stores can perform this comparison without
* reading and serializing matching cached text.
*/
def getCompleteTextsAccessibleTo(
factionId: FactionId,
knownCompleteTextIds: Set[ClientTextId]
knownCompleteTextByteCounts: Map[ClientTextId, Int]
): Vector[CompleteClientText] = {
val visibility = accessibleTo
val hidden = hiddenFromClients
completeTexts.values.filter { ct =>
!hidden.contains(ct.id) &&
!knownCompleteTextIds.contains(ct.id) &&
!knownCompleteTextByteCounts
.get(ct.id)
.contains(ct.text.getBytes(StandardCharsets.UTF_8).length) &&
visibility.get(ct.id).forall(fids => fids.isEmpty || fids.contains(factionId))
}.toVector
}
@@ -799,13 +799,22 @@ class PostgresClientTextStore private[client_text] (
override def getCompleteTextsAccessibleTo(
factionId: FactionId,
knownCompleteTextIds: Set[ClientTextId]
knownCompleteTextByteCounts: Map[ClientTextId, Int]
): Vector[CompleteClientText] = withConnection {
val knownCompleteCte =
if knownCompleteTextByteCounts.isEmpty then ""
else """WITH known_complete(text_id, byte_count) AS (
| SELECT * FROM unnest(?::text[], ?::integer[])
|)
|""".stripMargin
val knownCompleteFilter =
if knownCompleteTextIds.isEmpty then ""
else "AND NOT (t.id = ANY(?))"
if knownCompleteTextByteCounts.isEmpty then ""
else """AND NOT EXISTS (
| SELECT 1 FROM known_complete k
| WHERE k.text_id = t.id AND k.byte_count = octet_length(t.text)
|)""".stripMargin
val sql =
"""SELECT t.id, t.text, t.requested_after_history_count
"""%sSELECT t.id, t.text, t.requested_after_history_count
|FROM client_texts t
|WHERE t.status = 'complete'
|AND NOT EXISTS (SELECT 1 FROM client_text_hidden h WHERE h.text_id = t.id)
@@ -813,17 +822,24 @@ class PostgresClientTextStore private[client_text] (
|AND (
| NOT EXISTS (SELECT 1 FROM client_text_visibility v WHERE v.text_id = t.id)
| OR EXISTS (SELECT 1 FROM client_text_visibility v WHERE v.text_id = t.id AND v.faction_id = ?)
|)""".stripMargin.format(knownCompleteFilter)
|)""".stripMargin.format(knownCompleteCte, knownCompleteFilter)
val stmt = connection.prepareStatement(sql)
JfrEvents.postgresQuery(databaseName, "PostgresClientTextStore.getCompleteTextsAccessibleTo", sql) { event =>
var knownCompleteArray: java.sql.Array = null
var knownCompleteIdArray: java.sql.Array = null
var knownCompleteByteCountArray: java.sql.Array = null
try {
val nextParam =
if knownCompleteTextIds.isEmpty then 1
if knownCompleteTextByteCounts.isEmpty then 1
else {
knownCompleteArray = connection.createArrayOf("text", knownCompleteTextIds.toArray)
stmt.setArray(1, knownCompleteArray)
2
val entries = knownCompleteTextByteCounts.toVector
knownCompleteIdArray = connection.createArrayOf("text", entries.map(_._1).toArray)
knownCompleteByteCountArray = connection.createArrayOf(
"integer",
entries.map(entry => Int.box(entry._2)).toArray
)
stmt.setArray(1, knownCompleteIdArray)
stmt.setArray(2, knownCompleteByteCountArray)
3
}
stmt.setInt(nextParam, factionId)
val rs = stmt.executeQuery()
@@ -837,7 +853,8 @@ class PostgresClientTextStore private[client_text] (
event.rows = result.size
result.toVector
} finally {
if knownCompleteArray != null then knownCompleteArray.free()
if knownCompleteIdArray != null then knownCompleteIdArray.free()
if knownCompleteByteCountArray != null then knownCompleteByteCountArray.free()
stmt.close()
}
}
@@ -733,9 +733,9 @@ class SqliteClientTextStore private[client_text] (
/** Optimized single query to get all complete texts accessible to a faction */
override def getCompleteTextsAccessibleTo(
factionId: FactionId,
knownCompleteTextIds: Set[ClientTextId]
knownCompleteTextByteCounts: Map[ClientTextId, Int]
): Vector[CompleteClientText] = {
writeKnownCompleteTextIds(knownCompleteTextIds)
writeKnownCompleteTextByteCounts(knownCompleteTextByteCounts)
// A text is accessible if:
// 1. It has no visibility entries (accessible to everyone), OR
// 2. It has a visibility entry for this faction
@@ -744,7 +744,10 @@ class SqliteClientTextStore private[client_text] (
|FROM texts t
|WHERE t.status = 'complete'
|AND NOT EXISTS (SELECT 1 FROM hidden_texts h WHERE h.text_id = t.id)
|AND NOT EXISTS (SELECT 1 FROM temp_known_complete_text_ids k WHERE k.text_id = t.id)
|AND NOT EXISTS (
| SELECT 1 FROM temp_known_complete_texts k
| WHERE k.text_id = t.id AND k.byte_count = length(CAST(t.text AS BLOB))
|)
|AND (
| NOT EXISTS (SELECT 1 FROM visibility v WHERE v.text_id = t.id)
| OR EXISTS (SELECT 1 FROM visibility v WHERE v.text_id = t.id AND v.faction_id = ?)
@@ -764,11 +767,36 @@ class SqliteClientTextStore private[client_text] (
} finally stmt.close()
}
private def writeKnownCompleteTextIds(ids: Set[ClientTextId]): Unit =
writeTempTextIds(
tableName = "temp_known_complete_text_ids",
ids = ids
)
private def writeKnownCompleteTextByteCounts(byteCounts: Map[ClientTextId, Int]): Unit = {
val createStmt = connection.createStatement()
try
createStmt.execute(
"""CREATE TEMP TABLE IF NOT EXISTS temp_known_complete_texts (
| text_id TEXT PRIMARY KEY,
| byte_count INTEGER NOT NULL
|)""".stripMargin
): Unit
finally createStmt.close()
val deleteStmt = connection.createStatement()
try deleteStmt.execute("DELETE FROM temp_known_complete_texts"): Unit
finally deleteStmt.close()
if byteCounts.nonEmpty then {
val insertStmt = connection.prepareStatement(
"INSERT OR REPLACE INTO temp_known_complete_texts (text_id, byte_count) VALUES (?, ?)"
)
try {
byteCounts.foreach {
case (id, byteCount) =>
insertStmt.setString(1, id)
insertStmt.setInt(2, byteCount)
insertStmt.addBatch()
}
insertStmt.executeBatch(): Unit
} finally insertStmt.close()
}
}
private def writeTempClientTextIds(ids: Set[ClientTextId]): Unit =
writeTempTextIds(
@@ -1187,14 +1187,14 @@ final case class GameController(
0
} else knownResultCount
val knownCompleteTextIds = streamingTextStatuses
val knownCompleteTextByteCounts = streamingTextStatuses
.filter(_.knownComplete)
.map(_.llmIdentifier)
.toSet
.map(status => status.llmIdentifier -> status.knownByteCount)
.toMap
this.withHumanClient(
clientTextStore
.getCompleteTextsAccessibleTo(fid, knownCompleteTextIds)
.getCompleteTextsAccessibleTo(fid, knownCompleteTextByteCounts)
.foldLeft(
HumanPlayerClientConnectionState(
eagleGameId = gameId,
@@ -736,14 +736,14 @@ class ClientTextStoreImplTest extends AnyFlatSpec with MockFactory with Matchers
store.getCompleteTextsAccessibleTo(5) shouldBe empty
}
"getCompleteTextsAccessibleTo" should "exclude known complete text ids" in {
"getCompleteTextsAccessibleTo" should "exclude known complete text with a matching byte count" in {
val store = ClientTextStoreImpl(
pregenerated = pregeneratedClientTextStore,
persister = mockPersister,
completeTexts = Map(
"known" -> CompleteClientText(
id = "known",
text = "known text",
text = "café",
requestedAfterHistoryCount = requestedAfterHistoryCount
),
"unknown" -> CompleteClientText(
@@ -760,9 +760,14 @@ class ClientTextStoreImplTest extends AnyFlatSpec with MockFactory with Matchers
accessibleToIsSaved = false
)
store.getCompleteTextsAccessibleTo(5, knownCompleteTextIds = Set("known")) should contain theSameElementsAs Vector(
CompleteClientText("unknown", "unknown text", requestedAfterHistoryCount)
)
store.getCompleteTextsAccessibleTo(
5,
knownCompleteTextByteCounts = Map("known" -> 5)
) should contain theSameElementsAs Vector(CompleteClientText("unknown", "unknown text", requestedAfterHistoryCount))
store
.getCompleteTextsAccessibleTo(5, knownCompleteTextByteCounts = Map("known" -> 0))
.map(_.id) should contain theSameElementsAs Vector("known", "unknown")
}
"withoutTextsAfter" should "remove generated text rows after the rewind target" in {
@@ -414,14 +414,21 @@ class SqliteClientTextStoreTest extends AnyFlatSpec with Matchers with BeforeAnd
store.getCompleteTextsAccessibleTo(5) shouldBe empty
}
"getCompleteTextsAccessibleTo" should "exclude known complete text ids" in {
"getCompleteTextsAccessibleTo" should "exclude known complete text with a matching byte count" in {
val store = createStore()
.withAddedCompleteText("known", "known text", accessibleTo = Vector(5))
.withAddedCompleteText("known", "café", accessibleTo = Vector(5))
.withAddedCompleteText("unknown", "unknown text", accessibleTo = Vector(5))
store.getCompleteTextsAccessibleTo(5, knownCompleteTextIds = Set("known")) should contain theSameElementsAs Vector(
store.getCompleteTextsAccessibleTo(
5,
knownCompleteTextByteCounts = Map("known" -> 5)
) should contain theSameElementsAs Vector(
CompleteClientText("unknown", "unknown text", requestedAfterHistoryCount = 0)
)
store
.getCompleteTextsAccessibleTo(5, knownCompleteTextByteCounts = Map("known" -> 0))
.map(_.id) should contain theSameElementsAs Vector("known", "unknown")
}
"withMovedBackToUnrequested" should "move incomplete back to unrequested" in {