mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-28 20:55:44 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user