feat(processing): 领域/投影/终态三步提交与 FlightProjectionPort(ACM2-79)
PIPELINE_LOCK 跨 Redis 写;MSG_EVENT HELD→PENDING;Redis 失败不终态不投递;V3 迁移。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+22
-5
@@ -11,10 +11,10 @@ import java.sql.DriverManager
|
||||
|
||||
/**
|
||||
* 在真实 PostgreSQL 上跑一遍迁移链,确认结果符合预期:`V1__flight_state_baseline.sql`
|
||||
* 基线加 `V2__flight_chute_class_type_rename.sql` 更名依序执行成功,该建的表和 PIPELINE_LOCK
|
||||
* 单行种子都在,回填事实落在基线里,`INBOX_CURSOR`、`BACKFILL_TODO`、
|
||||
* `idx_evt_flid`、`PROC_STATE` 的处理开始时间列都不复存在;FLIGHT_CHUTE 的类字段列
|
||||
* 已由 V2 更名为 CCLS/CTYP(SIS 口径)。
|
||||
* 基线加 `V2__flight_chute_class_type_rename.sql` 更名、`V3__msg_event_hold.sql` 事件归属列
|
||||
* 依序执行成功,该建的表和 PIPELINE_LOCK 单行种子都在,回填事实落在基线里,`INBOX_CURSOR`、
|
||||
* `BACKFILL_TODO`、`idx_evt_flid`、`PROC_STATE` 的处理开始时间列都不复存在;FLIGHT_CHUTE
|
||||
* 的类字段列已由 V2 更名为 CCLS/CTYP(SIS 口径)。
|
||||
*
|
||||
* 没有可用的 PostgreSQL 时跳过(不假装通过)。
|
||||
*/
|
||||
@@ -51,11 +51,13 @@ class FlywayMigrationTest {
|
||||
while (rs.next()) {
|
||||
records.add(Triple(rs.getString("version"), rs.getString("script"), rs.getBoolean("success")))
|
||||
}
|
||||
assertEquals(2, records.size, "迁移链应为 V1 基线 + V2 更名两条")
|
||||
assertEquals(3, records.size, "迁移链应为 V1 基线 + V2 更名 + V3 事件归属三条")
|
||||
assertEquals("1", records[0].first)
|
||||
assertEquals("V1__flight_state_baseline.sql", records[0].second)
|
||||
assertEquals("2", records[1].first)
|
||||
assertEquals("V2__flight_chute_class_type_rename.sql", records[1].second)
|
||||
assertEquals("3", records[2].first)
|
||||
assertEquals("V3__msg_event_hold.sql", records[2].second)
|
||||
assertTrue(records.all { it.third })
|
||||
}
|
||||
|
||||
@@ -183,6 +185,21 @@ class FlywayMigrationTest {
|
||||
assertEquals(0, rs.getInt(1), "基线必须不含已无消费者的 idx_evt_flid")
|
||||
}
|
||||
|
||||
// V3:事件归属列与暂存行的部分索引(HELD 按 MSG_ID 清理与放行)
|
||||
stmt.executeQuery(
|
||||
"SELECT count(*) FROM information_schema.columns WHERE table_name = 'msg_event' " +
|
||||
"AND column_name = 'msg_id'",
|
||||
).use { rs ->
|
||||
assertTrue(rs.next())
|
||||
assertEquals(1, rs.getInt(1), "V3 必须加上 MSG_EVENT.MSG_ID")
|
||||
}
|
||||
stmt.executeQuery(
|
||||
"SELECT indexdef FROM pg_indexes WHERE tablename = 'msg_event' AND indexname = 'idx_evt_held'",
|
||||
).use { rs ->
|
||||
assertTrue(rs.next(), "V3 必须建立暂存行索引 idx_evt_held")
|
||||
assertTrue(rs.getString(1).contains("WHERE"), "idx_evt_held 只索引 HELD 行")
|
||||
}
|
||||
|
||||
// 基线:投递确认时间 SENT_AT 是保留期判定的唯一基准
|
||||
stmt.executeQuery(
|
||||
"SELECT count(*) FROM information_schema.columns WHERE table_name = 'msg_event' " +
|
||||
|
||||
+28
@@ -69,6 +69,34 @@ class JdbcMsgEventUpsertPgTest {
|
||||
assertNull(row.lastError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `held events are invisible to delivery until released and stale holds are discarded`() {
|
||||
val repo = JdbcMsgEventRepository(dataSource(), Clock.systemUTC())
|
||||
val key = "PG-" + UUID.randomUUID().toString().take(8)
|
||||
val msgId = System.nanoTime()
|
||||
|
||||
repo.insertAll(listOf(held(msgId, key, Targets.KAFKA_MSG, 1), held(msgId, key, Targets.KAFKA_SCHD, 1)))
|
||||
|
||||
assertTrue(repo.claimBatch(Targets.KAFKA_MSG, 1000).none { it.partitionKey == key }, "HELD 不进投递批")
|
||||
assertTrue(pendingSchd(repo, key).isEmpty(), "HELD 的整态行也领不到")
|
||||
|
||||
// 重处理:上一轮的暂存行先清掉,再重新登记
|
||||
assertEquals(2, repo.discardHeld(msgId))
|
||||
repo.insertAll(listOf(held(msgId, key, Targets.KAFKA_MSG, 2), held(msgId, key, Targets.KAFKA_SCHD, 2)))
|
||||
assertEquals(2, repo.releaseHeld(msgId))
|
||||
|
||||
assertEquals(1, repo.claimBatch(Targets.KAFKA_MSG, 1000).count { it.partitionKey == key })
|
||||
assertEquals(2, pendingSchd(repo, key).single().stateVersion)
|
||||
assertEquals(0, repo.releaseHeld(msgId), "已放行的行不会被重复放行")
|
||||
}
|
||||
|
||||
private fun held(msgId: Long, key: String, target: String, version: Long) =
|
||||
MsgEvent(
|
||||
msgId = msgId, target = target, partitionKey = key, stateVersion = version,
|
||||
payloadJson = """{"v":$version}""", state = com.gzzn.omms.msgexchange.domain.EventStatus.HELD,
|
||||
createdAt = now,
|
||||
)
|
||||
|
||||
private fun pendingSchd(repo: JdbcMsgEventRepository, key: String): List<MsgEvent> =
|
||||
repo.mergePendingSchd(now, 1000).filter { it.partitionKey == key }
|
||||
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
|
||||
|
||||
import com.gzzn.omms.msgexchange.support.PgTestSupport
|
||||
import com.zaxxer.hikari.HikariConfig
|
||||
import com.zaxxer.hikari.HikariDataSource
|
||||
import org.flywaydb.core.Flyway
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Assumptions.assumeTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.sql.DataSource
|
||||
|
||||
/**
|
||||
* 在真实 PostgreSQL 上验证跨事务持锁:`holdAcrossTransactions` 把连接钉住并持会话级咨询锁,
|
||||
* 块内的多次事务各自独立提交,另一个写者要等整段结束才拿得到锁
|
||||
* (docs/implementation.md「事务边界」)。拿不到 PG 时跳过,不假装通过。
|
||||
*/
|
||||
class JdbcPipelineLockPgTest {
|
||||
|
||||
@Test
|
||||
fun `each transaction inside one hold commits on its own`() {
|
||||
val ds = dataSource()
|
||||
val lock = JdbcPipelineLockRepository(ds)
|
||||
val tx = JdbcPipelineTransactionManager(ds)
|
||||
val key = "LOCK-" + UUID.randomUUID().toString().take(8)
|
||||
|
||||
// 第一段提交后中途失败:模拟"领域已提交、投影写挂了"
|
||||
assertThrows(IllegalStateException::class.java) {
|
||||
lock.holdAcrossTransactions {
|
||||
tx.inTransaction {
|
||||
lock.lock()
|
||||
insertHeldEvent(ds, key)
|
||||
}
|
||||
error("projection-down")
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(1, countEvents(ds, key), "已提交的第一段不因后续失败而回滚")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second writer waits until the whole hold is released`() {
|
||||
val ds = dataSource()
|
||||
val lock = JdbcPipelineLockRepository(ds)
|
||||
val tx = JdbcPipelineTransactionManager(ds)
|
||||
val holding = CountDownLatch(1)
|
||||
val release = CountDownLatch(1)
|
||||
val secondAcquired = CountDownLatch(1)
|
||||
|
||||
val holder = Thread.ofPlatform().daemon(true).start {
|
||||
lock.holdAcrossTransactions {
|
||||
tx.inTransaction { lock.lock() } // 第一段事务提交,行锁随之释放
|
||||
holding.countDown()
|
||||
release.await()
|
||||
}
|
||||
}
|
||||
val contender = Thread.ofPlatform().daemon(true).unstarted {
|
||||
tx.inTransaction {
|
||||
lock.lock()
|
||||
secondAcquired.countDown()
|
||||
}
|
||||
}
|
||||
try {
|
||||
assertTrue(holding.await(10, TimeUnit.SECONDS), "持锁方必须先进入")
|
||||
contender.start()
|
||||
assertFalse(secondAcquired.await(500, TimeUnit.MILLISECONDS), "行锁已释放,但整段仍由咨询锁挡着")
|
||||
release.countDown()
|
||||
assertTrue(secondAcquired.await(10, TimeUnit.SECONDS), "整段结束后第二个写者应立刻拿到锁")
|
||||
} finally {
|
||||
release.countDown()
|
||||
holder.join(5_000)
|
||||
contender.join(5_000)
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertHeldEvent(ds: DataSource, key: String) {
|
||||
ds.update(
|
||||
"INSERT INTO msg_event (target, partition_key, event_type, state_version, payload_json, state, attempts, created_at, msg_id) " +
|
||||
"VALUES ('KAFKA:msg', ?, 'UPSERT', 1, '{}', 'HELD', 0, now(), 1)",
|
||||
{ ps -> ps.setString(1, key) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun countEvents(ds: DataSource, key: String): Int =
|
||||
ds.queryOne("SELECT count(*) AS n FROM msg_event WHERE partition_key = ?", { ps -> ps.setString(1, key) }) {
|
||||
it.getInt("n")
|
||||
} ?: 0
|
||||
|
||||
private fun dataSource(): DataSource {
|
||||
assumeTrue(PgTestSupport.canConnect(), PgTestSupport.skipMessage())
|
||||
Flyway.configure()
|
||||
.dataSource(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password)
|
||||
.locations("classpath:db/migration")
|
||||
.load()
|
||||
.migrate()
|
||||
return HikariDataSource(
|
||||
HikariConfig().apply {
|
||||
jdbcUrl = PgTestSupport.jdbcUrl
|
||||
username = PgTestSupport.user
|
||||
password = PgTestSupport.password
|
||||
maximumPoolSize = 4
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -63,8 +63,9 @@ class FdelAndAdftProcessorTest {
|
||||
val proc = StubProcState()
|
||||
proc.insertIfAbsent(msgId, null)
|
||||
|
||||
val result = FdelProcessor(StubPipelineTx(), StubPipelineLock(), f, events, proc, ObjectMapper(), java.time.Clock.systemUTC())
|
||||
.apply(head(), msg(), FlopPayload("121"))
|
||||
val result = FdelProcessor(
|
||||
testCommit(procState = proc, msgEvents = events), f, events, ObjectMapper(), java.time.Clock.systemUTC(),
|
||||
).apply(head(), msg(), FlopPayload("121"))
|
||||
|
||||
assertEquals(ApplyResult.Succeeded, result)
|
||||
assertEquals(FlightState.DELETED, f.findMainRow("121")!!.state)
|
||||
@@ -84,11 +85,15 @@ class FdelAndAdftProcessorTest {
|
||||
fun `repeated FDEL is idempotent without version bump or duplicate event`() {
|
||||
val f = flights()
|
||||
val events = StubMsgEvents()
|
||||
val proc = FdelProcessor(StubPipelineTx(), StubPipelineLock(), f, events, StubProcState(), ObjectMapper(), java.time.Clock.systemUTC())
|
||||
val proc = FdelProcessor(
|
||||
testCommit(msgEvents = events), f, events, ObjectMapper(), java.time.Clock.systemUTC(),
|
||||
)
|
||||
|
||||
proc.apply(head(), msg(), FlopPayload("121"))
|
||||
val version = f.findMainRow("121")!!.stateVersion
|
||||
val result = proc.apply(head(), msg(), FlopPayload("121"))
|
||||
// 第二份删除报文是另一条消息(同一条消息到终态后不会再进主泵)
|
||||
val later = ProcState(msgId + 1, ProcStatus.PENDING, updatedAt = java.time.Instant.EPOCH)
|
||||
val result = proc.apply(later, msg(), FlopPayload("121"))
|
||||
|
||||
assertEquals(ApplyResult.Succeeded, result) // 重复的删除报文算成功,但不再动数据
|
||||
assertEquals(version, f.findMainRow("121")!!.stateVersion)
|
||||
@@ -100,8 +105,9 @@ class FdelAndAdftProcessorTest {
|
||||
val f = StubFlightState()
|
||||
val events = StubMsgEvents()
|
||||
|
||||
val result = FdelProcessor(StubPipelineTx(), StubPipelineLock(), f, events, StubProcState(), ObjectMapper(), java.time.Clock.systemUTC())
|
||||
.apply(head(), msg(), FlopPayload("999"))
|
||||
val result = FdelProcessor(
|
||||
testCommit(msgEvents = events), f, events, ObjectMapper(), java.time.Clock.systemUTC(),
|
||||
).apply(head(), msg(), FlopPayload("999"))
|
||||
|
||||
assertEquals(ApplyResult.Succeeded, result) // 航班不存在(迟到或多余)也算成功
|
||||
assertEquals(0, events.rows.size)
|
||||
@@ -113,7 +119,7 @@ class FdelAndAdftProcessorTest {
|
||||
f.markDeleted("121", msgId = 2, now = java.time.Instant.now())
|
||||
val events = StubMsgEvents()
|
||||
val adft = AdftProcessor(
|
||||
StubPipelineTx(), StubPipelineLock(), f, events, StubProcState(),
|
||||
testCommit(msgEvents = events), f, events,
|
||||
OperationDayProps().apply { zone = "Asia/Shanghai" }, ObjectMapper(), java.time.Clock.systemUTC(),
|
||||
)
|
||||
val record = com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord(
|
||||
@@ -134,7 +140,7 @@ class FdelAndAdftProcessorTest {
|
||||
val f = StubFlightState()
|
||||
val events = StubMsgEvents()
|
||||
val adft = AdftProcessor(
|
||||
StubPipelineTx(), StubPipelineLock(), f, events, StubProcState(),
|
||||
testCommit(msgEvents = events), f, events,
|
||||
OperationDayProps().apply { zone = "Asia/Shanghai" }, ObjectMapper(), java.time.Clock.systemUTC(),
|
||||
)
|
||||
|
||||
@@ -160,7 +166,7 @@ class FdelAndAdftProcessorTest {
|
||||
msgId = 1, now = java.time.Instant.now(),
|
||||
)
|
||||
val adft = AdftProcessor(
|
||||
StubPipelineTx(), StubPipelineLock(), f, StubMsgEvents(), StubProcState(),
|
||||
testCommit(), f, StubMsgEvents(),
|
||||
OperationDayProps().apply { zone = "Asia/Shanghai" }, ObjectMapper(), java.time.Clock.systemUTC(),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
package com.gzzn.omms.msgexchange.processing
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.gzzn.omms.msgexchange.MutableClock
|
||||
import com.gzzn.omms.msgexchange.codec.DecodeResult
|
||||
import com.gzzn.omms.msgexchange.codec.FlopPayload
|
||||
import com.gzzn.omms.msgexchange.codec.XmlCodec
|
||||
import com.gzzn.omms.msgexchange.config.OperationDayProps
|
||||
import com.gzzn.omms.msgexchange.config.PipelineProps
|
||||
import com.gzzn.omms.msgexchange.delivery.Dispatcher
|
||||
import com.gzzn.omms.msgexchange.domain.DecodedMessage
|
||||
import com.gzzn.omms.msgexchange.domain.ErrorClass
|
||||
import com.gzzn.omms.msgexchange.domain.EventStatus
|
||||
import com.gzzn.omms.msgexchange.domain.MetaFields
|
||||
import com.gzzn.omms.msgexchange.domain.MsgKind
|
||||
import com.gzzn.omms.msgexchange.domain.ProcState
|
||||
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
||||
import com.gzzn.omms.msgexchange.domain.Targets
|
||||
import com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot
|
||||
import com.gzzn.omms.msgexchange.domain.flight.FlightState
|
||||
import com.gzzn.omms.msgexchange.infra.metrics.PipelineCounters
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
|
||||
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
|
||||
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubDeliveryPort
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubFlightProjectionPort
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubFlightState
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubInbox
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubSnapshotLog
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* 三步提交(`INV-3`、`INV-10`)的故障注入:
|
||||
* 投影写不成功时,已提交的航班数据不撤销,但这条消息不进终态、不记回填意图,
|
||||
* 待发事件停在 `HELD` 发不出去;恢复后重处理只留一次效果。
|
||||
*/
|
||||
class FlightCommitTest {
|
||||
|
||||
private val clock = MutableClock(MutableClock.BASE)
|
||||
private val mapper = ObjectMapper()
|
||||
private val msgId = 21L
|
||||
private val flid = "121"
|
||||
|
||||
private fun head() = ProcState(msgId, ProcStatus.PENDING, updatedAt = MutableClock.BASE)
|
||||
|
||||
/** 处理器不读报文体以外的内容,几个用例共用这一条 FDEL 形状的解码结果。 */
|
||||
private fun msg() = DecodedMessage(
|
||||
meta = MetaFields("AODB", "FDEL", "", 9L, 1L),
|
||||
kind = MsgKind.Fdel,
|
||||
rawXml = "<MSG/>",
|
||||
body = FlopPayload(flid),
|
||||
)
|
||||
|
||||
/** 一套内存装置:航班 121 已在库、版本 1。 */
|
||||
private inner class Fixture {
|
||||
val flights = StubFlightState()
|
||||
val events = StubMsgEvents()
|
||||
val procState = StubProcState()
|
||||
val projection = StubFlightProjectionPort()
|
||||
|
||||
init {
|
||||
flights.persistFullState(
|
||||
FlightSnapshot(flid, LocalDate.of(2026, 12, 15), FlightState.ACTIVE, 1, mapOf("FLNO" to "CA001"), emptyMap()),
|
||||
msgId = 1,
|
||||
now = MutableClock.BASE,
|
||||
)
|
||||
procState.insertIfAbsent(msgId, MutableClock.BASE)
|
||||
}
|
||||
|
||||
/** 每次新建处理器:等价于重启后由新进程接着处理同一条消息。 */
|
||||
fun flop() = FlopProcessor(commit(), flights, events, mapper, clock)
|
||||
|
||||
fun fdel() = FdelProcessor(commit(), flights, events, mapper, clock)
|
||||
|
||||
private fun commit() = testCommit(
|
||||
procState = procState, msgEvents = events, projection = projection, clock = clock,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `projection failure keeps committed flight data but leaves the message unfinished`() {
|
||||
val f = Fixture()
|
||||
f.projection.failWith = IllegalStateException("redis-down")
|
||||
|
||||
assertThrows(FlightProjectionFailure::class.java) { f.flop().apply(head(), msg(), FlopPayload(flid)) }
|
||||
|
||||
// 已提交的 PG 不因投影失败而撤销(US-03 AC3)
|
||||
assertEquals(2L, f.flights.findMainRow(flid)!!.stateVersion)
|
||||
// 不记终态、不记回填意图(INV-10)
|
||||
val row = f.procState.find(msgId)!!
|
||||
assertEquals(ProcStatus.PENDING, row.state)
|
||||
assertNull(row.backfillNextAt)
|
||||
// 事件登记了但发不出去:投递只领 PENDING
|
||||
assertTrue(f.events.rows.values.all { it.state == EventStatus.HELD })
|
||||
assertTrue(f.events.claimBatch(Targets.KAFKA_MSG, 10).isEmpty())
|
||||
assertTrue(f.events.mergePendingSchd(clock.instant(), 10).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reprocessing after recovery releases each event exactly once`() {
|
||||
val f = Fixture()
|
||||
f.projection.failWith = IllegalStateException("redis-down")
|
||||
assertThrows(FlightProjectionFailure::class.java) { f.flop().apply(head(), msg(), FlopPayload(flid)) }
|
||||
|
||||
f.projection.failWith = null
|
||||
val result = f.flop().apply(head(), msg(), FlopPayload(flid)) // 新处理器 = 重启后重处理
|
||||
|
||||
assertEquals(ApplyResult.Succeeded, result)
|
||||
val row = f.procState.find(msgId)!!
|
||||
assertEquals(ProcStatus.SUCCEEDED, row.state)
|
||||
assertNotNull(row.backfillNextAt)
|
||||
// 上一轮的暂存行先清后补:通知不重复,整态仍是每个 FLID 一行
|
||||
assertEquals(1, f.events.rows.values.count { it.target == Targets.KAFKA_MSG })
|
||||
assertEquals(1, f.events.rows.values.count { it.target == Targets.KAFKA_SCHD })
|
||||
assertTrue(f.events.rows.values.all { it.state == EventStatus.PENDING })
|
||||
assertEquals(3L, f.flights.findMainRow(flid)!!.stateVersion) // 领域再跑一次,版本推进
|
||||
assertTrue(f.projection.snapshots.containsKey(flid))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FDEL removes the flight from the projection`() {
|
||||
val f = Fixture()
|
||||
f.projection.snapshots[flid] = """{"flid":"121"}"""
|
||||
|
||||
val result = f.fdel().apply(head(), msg(), FlopPayload(flid))
|
||||
|
||||
assertEquals(ApplyResult.Succeeded, result)
|
||||
assertFalse(f.projection.snapshots.containsKey(flid)) // INV-8:标删即从投影移除
|
||||
assertTrue(f.projection.writes.single() is FlightProjectionWrite.Delete)
|
||||
assertEquals(ProcStatus.SUCCEEDED, f.procState.find(msgId)!!.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `projection failure on FDEL keeps the deletion committed and the projection entry untouched`() {
|
||||
val f = Fixture()
|
||||
f.projection.snapshots[flid] = """{"flid":"121"}"""
|
||||
f.projection.failWith = IllegalStateException("redis-down")
|
||||
|
||||
assertThrows(FlightProjectionFailure::class.java) { f.fdel().apply(head(), msg(), FlopPayload(flid)) }
|
||||
|
||||
assertEquals(FlightState.DELETED, f.flights.findMainRow(flid)!!.state)
|
||||
assertTrue(f.projection.snapshots.containsKey(flid)) // 投影还在,下轮重删
|
||||
assertEquals(ProcStatus.PENDING, f.procState.find(msgId)!!.state)
|
||||
|
||||
f.projection.failWith = null
|
||||
f.fdel().apply(head(), msg(), FlopPayload(flid))
|
||||
|
||||
assertFalse(f.projection.snapshots.containsKey(flid))
|
||||
assertEquals(ProcStatus.SUCCEEDED, f.procState.find(msgId)!!.state)
|
||||
// 重复的 FDEL 不再推进版本,也不重复登记删除通知
|
||||
assertEquals(1, f.events.rows.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lock is held from domain commit through projection write to terminal transaction`() {
|
||||
val trace = mutableListOf<String>()
|
||||
val flights = StubFlightState()
|
||||
flights.persistFullState(
|
||||
FlightSnapshot(flid, LocalDate.of(2026, 12, 15), FlightState.ACTIVE, 1, mapOf("FLNO" to "CA001"), emptyMap()),
|
||||
msgId = 1, now = MutableClock.BASE,
|
||||
)
|
||||
val events = StubMsgEvents()
|
||||
val commit = FlightCommit(
|
||||
txManager = TracingTx(trace),
|
||||
lock = TracingLock(trace),
|
||||
procState = StubProcState(),
|
||||
msgEvents = events,
|
||||
projection = TracingProjection(trace),
|
||||
clock = clock,
|
||||
)
|
||||
|
||||
FlopProcessor(commit, flights, events, mapper, clock).apply(head(), msg(), FlopPayload(flid))
|
||||
|
||||
assertEquals(
|
||||
listOf("hold", "tx-begin", "lock", "tx-end", "projection", "tx-begin", "lock", "tx-end", "release"),
|
||||
trace,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `main pump records a retryable failure and delivers nothing when the projection is down`() {
|
||||
val f = Fixture()
|
||||
f.projection.failWith = IllegalStateException("redis-down")
|
||||
val inbox = StubInbox()
|
||||
inbox.raws[msgId] = "<RAW/>"
|
||||
val props = PipelineProps()
|
||||
val opDay = OperationDayProps().apply { zone = "Asia/Shanghai"; cutoffHour = 0 }
|
||||
val commit = testCommit(procState = f.procState, msgEvents = f.events, projection = f.projection, clock = clock)
|
||||
val processor = MessageProcessor(
|
||||
inbox = inbox,
|
||||
procState = f.procState,
|
||||
codec = codecReturning(msg()),
|
||||
scheduleProcessor = ScheduleProcessor(commit, f.procState, f.flights, f.events, StubSnapshotLog(), opDay, mapper, clock),
|
||||
flopProcessor = FlopProcessor(commit, f.flights, f.events, mapper, clock),
|
||||
fdelProcessor = FdelProcessor(commit, f.flights, f.events, mapper, clock),
|
||||
adftProcessor = AdftProcessor(commit, f.flights, f.events, opDay, mapper, clock),
|
||||
procFailure = ProcFailure(f.procState, FailureScheduler(props, clock)),
|
||||
props = props,
|
||||
clock = clock,
|
||||
operationDayProps = opDay,
|
||||
counters = PipelineCounters(),
|
||||
)
|
||||
|
||||
processor.processOne(head())
|
||||
|
||||
val row = f.procState.find(msgId)!!
|
||||
assertEquals(ProcStatus.FAILED, row.state) // 可重试失败,不是终态
|
||||
assertEquals(ErrorClass.INFRA, row.errorClass)
|
||||
assertNull(row.backfillNextAt) // 没有回填意图,信箱标记不会被写回
|
||||
|
||||
val port = StubDeliveryPort()
|
||||
val deliveryProps = PipelineProps().apply { pipeline.pollInterval = Duration.ofMillis(1) }
|
||||
Dispatcher(f.events, port, deliveryProps, FailureScheduler(deliveryProps, clock), PipelineCounters(), mapper).tick()
|
||||
|
||||
assertTrue(port.sent.isEmpty()) // 处理完成前不发 Kafka(INV-3)
|
||||
}
|
||||
|
||||
private fun codecReturning(decoded: DecodedMessage) = object : XmlCodec {
|
||||
override fun decode(rawXml: String): DecodeResult = DecodeResult.Ok(decoded)
|
||||
override fun encodeRqrd(kind: String, rangeJson: String): String = ""
|
||||
}
|
||||
|
||||
private class TracingTx(private val trace: MutableList<String>) : PipelineTransactionManager {
|
||||
override fun <T> inTransaction(block: () -> T): T {
|
||||
trace += "tx-begin"
|
||||
val result = block()
|
||||
trace += "tx-end"
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private class TracingLock(private val trace: MutableList<String>) : PipelineLockRepository {
|
||||
override fun lock() {
|
||||
trace += "lock"
|
||||
}
|
||||
|
||||
override fun <T> holdAcrossTransactions(block: () -> T): T {
|
||||
trace += "hold"
|
||||
try {
|
||||
return block()
|
||||
} finally {
|
||||
trace += "release"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class TracingProjection(private val trace: MutableList<String>) : FlightProjectionPort {
|
||||
override fun write(writes: List<FlightProjectionWrite>) {
|
||||
trace += "projection"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,11 +50,6 @@ class IgnoreBranchTest {
|
||||
override fun encodeRqrd(kind: String, rangeJson: String): String = ""
|
||||
}
|
||||
|
||||
private val noopTx = object : PipelineTransactionManager {
|
||||
override fun <T> inTransaction(block: () -> T): T = block()
|
||||
}
|
||||
private val noopLock = object : PipelineLockRepository { override fun lock() = Unit }
|
||||
|
||||
private fun processor(
|
||||
proc: StubProcState = StubProcState(),
|
||||
inbox: StubInbox = StubInbox(),
|
||||
@@ -68,14 +63,15 @@ class IgnoreBranchTest {
|
||||
val events = StubMsgEvents()
|
||||
val log = StubSnapshotLog()
|
||||
val procFailure = ProcFailure(proc, FailureScheduler(props, clock))
|
||||
val commit = testCommit(procState = proc, msgEvents = events, clock = clock)
|
||||
return MessageProcessor(
|
||||
inbox = inbox,
|
||||
procState = proc,
|
||||
codec = codec,
|
||||
scheduleProcessor = ScheduleProcessor(noopTx, noopLock, proc, flights, events, log, opDay, ObjectMapper(), clock),
|
||||
flopProcessor = FlopProcessor(noopTx, noopLock, flights, events, proc, ObjectMapper(), clock),
|
||||
fdelProcessor = FdelProcessor(noopTx, noopLock, flights, events, proc, ObjectMapper(), clock),
|
||||
adftProcessor = AdftProcessor(noopTx, noopLock, flights, events, proc, opDay, ObjectMapper(), clock),
|
||||
scheduleProcessor = ScheduleProcessor(commit, proc, flights, events, log, opDay, ObjectMapper(), clock),
|
||||
flopProcessor = FlopProcessor(commit, flights, events, ObjectMapper(), clock),
|
||||
fdelProcessor = FdelProcessor(commit, flights, events, ObjectMapper(), clock),
|
||||
adftProcessor = AdftProcessor(commit, flights, events, opDay, ObjectMapper(), clock),
|
||||
procFailure = procFailure,
|
||||
props = props,
|
||||
clock = clock,
|
||||
|
||||
@@ -15,10 +15,7 @@ import com.gzzn.omms.msgexchange.domain.flight.FlightState
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.SnapshotLogRepository
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubFlightState
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
|
||||
@@ -75,8 +72,7 @@ class ScheduleProcessorTest {
|
||||
override fun <T> inTransaction(block: () -> T): T = block()
|
||||
}
|
||||
return ScheduleProcessor(
|
||||
txManager = txRunner,
|
||||
lock = object : PipelineLockRepository { override fun lock() = Unit },
|
||||
commit = testCommit(procState = proc, msgEvents = events, txManager = txRunner),
|
||||
procState = proc,
|
||||
flightState = flights,
|
||||
msgEvents = events,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.gzzn.omms.msgexchange.processing
|
||||
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubFlightProjectionPort
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineLock
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineTx
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
|
||||
import java.time.Clock
|
||||
|
||||
/**
|
||||
* 处理器用例共用的三步提交装配:默认全内存、投影不失败。
|
||||
* 事件仓储要和处理器用同一个实例,否则放行(`HELD` → `PENDING`)落不到处理器写的那些行上。
|
||||
*/
|
||||
internal fun testCommit(
|
||||
procState: ProcStateRepository = StubProcState(),
|
||||
msgEvents: MsgEventRepository = StubMsgEvents(),
|
||||
projection: FlightProjectionPort = StubFlightProjectionPort(),
|
||||
lock: PipelineLockRepository = StubPipelineLock(),
|
||||
txManager: PipelineTransactionManager = StubPipelineTx(),
|
||||
clock: Clock = Clock.systemUTC(),
|
||||
) = FlightCommit(txManager, lock, procState, msgEvents, projection, clock)
|
||||
Reference in New Issue
Block a user