fix(delivery): upsert latest schedule event
KAFKA:schd 收敛为每 FLID 单行:V9 先按 (STATE_VERSION DESC, EVENT_ID DESC) 收敛存量再建部分唯一索引 uq_schd_event;写入走带谓词的单行 upsert(只进不退、同版本 tombstone 优先、接受新代次即换 EVENT_ID 并重置 PENDING/清旧错误);读取不再 DISTINCT ON;成功确认改按 (EVENT_ID, STATE_VERSION, PENDING) 条件更新,删除 Dispatcher superseded 清理循环。msg 路径不变。 验证:全量 ./gradlew test 129 tests / 0 fail(新增 3 个用例:单行只进不退、同版本 tombstone 不可复活、发送期间新代次保持 PENDING)。V9 的存量收敛/索引与 upsert SQL 需真实 PG,本机无 Docker 时 FlywayMigrationTest 跳过。
This commit is contained in:
@@ -2,6 +2,7 @@ package com.gzzn.omms.msgexchange.delivery
|
||||
|
||||
import com.gzzn.omms.msgexchange.MutableClock
|
||||
import com.gzzn.omms.msgexchange.config.PipelineProps
|
||||
import com.gzzn.omms.msgexchange.domain.EventStatus
|
||||
import com.gzzn.omms.msgexchange.domain.EventType
|
||||
import com.gzzn.omms.msgexchange.domain.MsgEvent
|
||||
import com.gzzn.omms.msgexchange.domain.Targets
|
||||
@@ -63,7 +64,7 @@ class DispatcherTickTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flushSchd aggregates latest version per flight and marks sent`() {
|
||||
fun `flushSchd sends one row per flight and marks it sent`() {
|
||||
val repo = StubMsgEvents()
|
||||
val port = StubDeliveryPort()
|
||||
repo.insertAll(
|
||||
@@ -134,6 +135,52 @@ class DispatcherTickTest {
|
||||
assertTrue(final.attempts >= p.pipeline.maxAttempts || final.state.name == "DEAD")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `schd writes keep one row per flight and never regress to an older version`() {
|
||||
val repo = StubMsgEvents()
|
||||
|
||||
repo.insertAll(listOf(ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"v1"}""", 1)))
|
||||
repo.insertAll(listOf(ev(2, Targets.KAFKA_SCHD, "F1", """{"v":"v2"}""", 2)))
|
||||
repo.insertAll(listOf(ev(3, Targets.KAFKA_SCHD, "F1", """{"v":"late-old"}""", 1)))
|
||||
|
||||
val row = repo.rows.values.single()
|
||||
assertEquals("F1", row.partitionKey)
|
||||
assertEquals(2, row.stateVersion)
|
||||
assertTrue(row.payloadJson.contains("v2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same version tombstone wins and a later upsert cannot revive it`() {
|
||||
val repo = StubMsgEvents()
|
||||
repo.insertAll(listOf(ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"v3"}""", 3)))
|
||||
|
||||
repo.insertAll(
|
||||
listOf(
|
||||
MsgEvent(
|
||||
target = Targets.KAFKA_SCHD, partitionKey = "F1",
|
||||
eventType = EventType.TOMBSTONE, stateVersion = 3, payloadJson = """{"flid":"F1","deleted":true}""",
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals(EventType.TOMBSTONE, repo.rows.values.single().eventType)
|
||||
|
||||
repo.insertAll(listOf(ev(9, Targets.KAFKA_SCHD, "F1", """{"v":"v3-again"}""", 3)))
|
||||
assertEquals(EventType.TOMBSTONE, repo.rows.values.single().eventType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `new generation written during send keeps the row pending for the next round`() {
|
||||
val repo = StubMsgEvents()
|
||||
repo.insertAll(listOf(ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"old"}""", 1)))
|
||||
|
||||
dispatcher(repo, ReentrantSchdPort(repo)).flushSchd()
|
||||
|
||||
val row = repo.rows.values.single()
|
||||
assertEquals(99, row.stateVersion)
|
||||
assertTrue(row.payloadJson.contains("new"))
|
||||
assertEquals(EventStatus.PENDING, row.state) // 旧代次的确认影响 0 行,不把新内容带走
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `KAFKA msg delivery unaffected while schd batch is retrying`() {
|
||||
val repo = StubMsgEvents()
|
||||
@@ -158,3 +205,17 @@ private class FailingSchdPort : DeliveryPort {
|
||||
throw IllegalStateException("broker-down")
|
||||
}
|
||||
}
|
||||
|
||||
/** 在发送过程中给同一 FLID 写入新代次,用来验证「条件确认」不会把新内容标记成已发。 */
|
||||
private class ReentrantSchdPort(private val repo: StubMsgEvents) : DeliveryPort {
|
||||
override fun sendKafka(topic: String, key: String, payloadJson: String) = Unit
|
||||
override fun sendKafkaSchd(topic: String, key: String, payloadJson: String) {
|
||||
repo.insertAll(
|
||||
listOf(
|
||||
MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = key, stateVersion = 99, payloadJson = """{"v":"new"}"""),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun sendKafkaNull(topic: String, key: String) = Unit
|
||||
}
|
||||
|
||||
+31
-1
@@ -49,7 +49,7 @@ class FlywayMigrationTest {
|
||||
while (rs.next()) {
|
||||
records.add(Triple(rs.getString("version"), rs.getString("script"), rs.getBoolean("success")))
|
||||
}
|
||||
assertTrue(records.size >= 8, "flyway_schema_history must record all migrations")
|
||||
assertTrue(records.size >= 9, "flyway_schema_history must record all migrations")
|
||||
assertEquals("1", records[0].first)
|
||||
assertEquals("V1__flight_state_baseline.sql", records[0].second)
|
||||
assertEquals("2", records[1].first)
|
||||
@@ -66,6 +66,8 @@ class FlywayMigrationTest {
|
||||
assertEquals("V7__drop_processing_started_at.sql", records[6].second)
|
||||
assertEquals("8", records[7].first)
|
||||
assertEquals("V8__req_track_open_unique.sql", records[7].second)
|
||||
assertEquals("9", records[8].first)
|
||||
assertEquals("V9__schd_single_row.sql", records[8].second)
|
||||
assertTrue(records.all { it.third })
|
||||
}
|
||||
|
||||
@@ -148,6 +150,34 @@ class FlywayMigrationTest {
|
||||
"VALUES ('RQFD-NONE', DATE '2026-09-12', 'RMS', 'PENDING', now())",
|
||||
)
|
||||
|
||||
// V9:KAFKA:schd 每个 FLID 单行(部分唯一索引),KAFKA:msg 仍可多行
|
||||
stmt.executeQuery(
|
||||
"SELECT indexdef FROM pg_indexes WHERE tablename = 'msg_event' AND indexname = 'uq_schd_event'",
|
||||
).use { rs ->
|
||||
assertTrue(rs.next(), "V9 必须建立 schd 单行唯一索引 uq_schd_event")
|
||||
val indexDef = rs.getString(1)
|
||||
assertTrue(indexDef.contains("UNIQUE"), "uq_schd_event 必须是唯一索引:$indexDef")
|
||||
assertTrue(indexDef.contains("WHERE"), "uq_schd_event 必须是仅约束 schd 的部分索引:$indexDef")
|
||||
}
|
||||
stmt.executeUpdate(
|
||||
"INSERT INTO msg_event (target, partition_key, event_type, state_version, payload_json, state, attempts, created_at) " +
|
||||
"VALUES ('KAFKA:schd', 'F1', 'UPSERT', 1, '{}', 'PENDING', 0, now())",
|
||||
)
|
||||
assertThrows(java.sql.SQLException::class.java) {
|
||||
stmt.executeUpdate(
|
||||
"INSERT INTO msg_event (target, partition_key, event_type, state_version, payload_json, state, attempts, created_at) " +
|
||||
"VALUES ('KAFKA:schd', 'F1', 'UPSERT', 2, '{}', 'PENDING', 0, now())",
|
||||
)
|
||||
}
|
||||
stmt.executeUpdate(
|
||||
"INSERT INTO msg_event (target, partition_key, event_type, state_version, payload_json, state, attempts, created_at) " +
|
||||
"VALUES ('KAFKA:msg', 'F1', 'UPSERT', 1, '{}', 'PENDING', 0, now())",
|
||||
)
|
||||
stmt.executeUpdate(
|
||||
"INSERT INTO msg_event (target, partition_key, event_type, state_version, payload_json, state, attempts, created_at) " +
|
||||
"VALUES ('KAFKA:msg', 'F1', 'UPSERT', 2, '{}', 'PENDING', 0, now())",
|
||||
)
|
||||
|
||||
// V6:入队时间是超期判据 R 的比较对象,必须非空(received_at 则允许为 NULL)
|
||||
stmt.executeQuery(
|
||||
"SELECT is_nullable FROM information_schema.columns " +
|
||||
|
||||
Reference in New Issue
Block a user