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:
windyboy
2026-09-21 15:24:34 +08:00
co-authored by Cursor
parent 891088235c
commit 2c3d6af309
28 changed files with 928 additions and 167 deletions
@@ -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/CTYPSIS 口径)。
* 基线加 `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/CTYPSIS 口径)。
*
* 没有可用的 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' " +
@@ -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 }
@@ -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()) // 处理完成前不发 KafkaINV-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)