feat(wave4): GET /all/flights and SCHD day-plan processing (ACM2-80, ACM2-85)

Expose projection read API with MAID filtering and 503 on failure; complete schedule snapshot sweep, field clearing, batched PG via FlightCommit, and contract/PARAM docs with regression tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
windyboy
2026-09-21 15:40:58 +08:00
co-authored by Cursor
parent 2c3d6af309
commit b545fd2b58
22 changed files with 491 additions and 114 deletions
@@ -75,21 +75,7 @@ class FdelProcessor(
// 重处理开始时已把上一轮没放行的事件清掉,这里不补登就会把这条通知丢了。
if (deleted || (main.state == FlightState.DELETED && main.lastMsgId == head.msgId)) {
msgEvents.insertAll(
listOf(
// C-9:删除通知只走 KAFKA:msgschd 不再发 tombstone
// value 形态("deleted":true 的 JSON)沿用现状,待 Q5 定稿
MsgEvent(
msgId = head.msgId,
target = Targets.KAFKA_MSG,
partitionKey = payload.flid,
stateVersion = main.stateVersion,
payloadJson = mapper.writeValueAsString(
mapOf("flid" to payload.flid, "stateVersion" to main.stateVersion, "deleted" to true),
),
state = EventStatus.HELD,
createdAt = clock.instant(),
),
),
listOf(deleteEvent(payload.flid, main.stateVersion, head.msgId, mapper, clock.instant())),
)
}
// INV-8:只要航班是删除态就得从投影里抹掉。重复报文也重删一次——代价是一条幂等命令,
@@ -202,6 +188,28 @@ internal fun flightPayload(next: FlightSnapshot): Map<String, Any> = linkedMapOf
"collections" to next.collections,
)
/**
* 删除通知:只走 `KAFKA:msg``schd` 不发 tombstone`C-9`)。
* FDEL 与日计划覆盖范围内的缺席删除(`INV-7`)共用这一份形状;value 形态待 `Q5` 定稿。
*/
internal fun deleteEvent(
flid: String,
stateVersion: Long,
msgId: Long,
mapper: ObjectMapper,
createdAt: Instant,
): MsgEvent = MsgEvent(
msgId = msgId,
target = Targets.KAFKA_MSG,
partitionKey = flid,
stateVersion = stateVersion,
payloadJson = mapper.writeValueAsString(
mapOf("flid" to flid, "stateVersion" to stateVersion, "deleted" to true),
),
state = EventStatus.HELD,
createdAt = createdAt,
)
/**
* 航班状态变化后要发的两类事件:KAFKA_SCHD 发完整状态,KAFKA_MSG 发一条变更通知。
*
@@ -22,6 +22,8 @@ data class DomainOutcome<T>(val value: T, val projections: List<FlightProjection
* 2. 写 Redis 投影:失败就抛出去,这条消息保持未完成,下轮重处理;
* 3. 终态事务:放行待发事件,写 `SUCCEEDED` 与回填意图。
*
* 日计划要分批写库,第一步因此可以拆成多个事务([commitBatched]),后两步不变。
*
* 三步整段持 `PIPELINE_LOCK`:行锁提交即释放,盖不住中间的投影写,所以由
* [PipelineLockRepository.holdAcrossTransactions] 在一条连接上从头持到尾。
*
@@ -37,21 +39,53 @@ class FlightCommit(
private val projection: FlightProjectionPort,
private val clock: Clock,
) {
fun <T> commit(head: ProcState, domain: () -> DomainOutcome<T>): T = lock.holdAcrossTransactions {
val outcome = txManager.inTransaction {
lock.lock()
msgEvents.discardHeld(head.msgId) // 清掉上一轮没放行的事件,再登记本轮的
domain()
}
fun <T> commit(head: ProcState, domain: () -> DomainOutcome<T>): T =
commitBatched(head) { scope -> scope.batch(domain) }
writeProjection(head, outcome.projections)
/**
* 分批版三步提交(`INV-9`、`US-07` AC4):领域按 [BatchScope.batch] 分成多个事务,
* 整份写完之后才刷投影、才写终态。
*
* 投影写攒到最后一次发出,所以"PG 整份写完且投影按快照刷完"才是处理完成的条件。
* 中途失败时已提交的批次不回滚(`US-03` AC3),这条消息保持未完成、下轮整包重来:
* 重来时第一批会先清掉上一轮没放行的 `HELD` 事件,不会重复投递。
*
* 块内也可以直读(读走同一条被钉住的连接),但写一律放进 [BatchScope.batch]。
*/
fun <T> commitBatched(head: ProcState, work: (BatchScope) -> T): T = lock.holdAcrossTransactions {
val scope = Batches(head)
val value = work(scope)
writeProjection(head, scope.projections)
txManager.inTransaction {
lock.lock()
msgEvents.releaseHeld(head.msgId)
procState.markTerminal(head.msgId, ProcStatus.SUCCEEDED, now = clock.instant())
}
outcome.value
value
}
/** 分批提交的入口:每调用一次 [batch] 就是一个领域事务。 */
interface BatchScope {
fun <T> batch(domain: () -> DomainOutcome<T>): T
}
private inner class Batches(private val head: ProcState) : BatchScope {
val projections = mutableListOf<FlightProjectionWrite>()
private var firstBatch = true
override fun <T> batch(domain: () -> DomainOutcome<T>): T = txManager.inTransaction {
lock.lock()
// 清残只在第一批做:上一轮没放行的事件要在本轮登记之前清掉,之后的批次不能再清
if (firstBatch) {
msgEvents.discardHeld(head.msgId)
firstBatch = false
}
val outcome = domain()
projections += outcome.projections
outcome.value
}
}
private fun writeProjection(head: ProcState, writes: List<FlightProjectionWrite>) {
@@ -30,6 +30,14 @@ interface FlightProjectionPort {
/** 一批一起写:同一条消息产生的写要么整批成功,要么抛异常整批重来。 */
fun write(writes: List<FlightProjectionWrite>)
/**
* 读出投影里当前全部航班的整态 JSON,顺序不承诺。
*
* 读不到时**必须抛异常**:空列表是"投影里一个航班都没有"的事实,不能用来顶替"读不到"
* `INV-11`、`US-12` AC2)。查询与网页客户端读的是同一份(`C-11`)。
*/
fun readAll(): List<String>
/** 给健康检查用的连通性探测。 */
fun ping(): Boolean = true
}
@@ -3,6 +3,7 @@ package com.gzzn.omms.msgexchange.processing
import com.fasterxml.jackson.databind.ObjectMapper
import com.gzzn.omms.msgexchange.codec.ScheduleBody
import com.gzzn.omms.msgexchange.config.OperationDayProps
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.ProcState
@@ -44,11 +45,12 @@ class ProtocolViolation(message: String) : RuntimeException(message)
/**
* 处理 SCHD 日计划报文(DNLD 和 RESP 走同一条路):把报文里的航班记录合并进航班当前态。
*
* 顺序是:整包校验 → 加锁 → 核对每条航班的运营日 → 逐条合并写入登记待发事件 →
* 刷投影 → 写终态和回填待办。领域这一段在同一个事务里,任何一步失败整包回滚,
* 不会留下写了一半的数据;投影与终态按三步提交各走一步([FlightCommit])。
* 顺序是:整包校验 → 加锁 → 核对每条航班的运营日 → 分批合并写入登记待发事件 →
* 清扫覆盖范围内缺席的航班 → 刷投影 → 写终态和回填待办。
* 校验或运营日核对不过就整包拒绝、一条都不落(`INV-4`);写入阶段按批分事务,
* 整份写完且投影刷完才算处理完成,中途失败下轮整包重来(`INV-9`)。
*
* 报文里没提到的航班不会被删除——日计划只负责写它带来的那部分
* 日计划是覆盖范围内的完整列表:范围内缺席的航班要标删(`INV-7`),范围外的不受影响
*/
@Singleton
class ScheduleProcessor(
@@ -58,6 +60,7 @@ class ScheduleProcessor(
private val msgEvents: MsgEventRepository,
private val snapshotLog: SnapshotLogRepository,
operationDayProps: OperationDayProps,
private val props: PipelineProps,
private val mapper: ObjectMapper,
private val clock: Clock,
) {
@@ -97,11 +100,13 @@ class ScheduleProcessor(
}
val flags = linkedSetOf<SnapshotFlag>()
val batchSize = props.schd.snapshotBatch
return try {
// 领域这一段在同一个事务里:加锁 → 校验运营日 → 逐条合并写入 → 登记事件;
// 投影与终态由 FlightCommit 接着走后两步
val upserted = commit.commit(head) {
// 一次批量查出这些航班现有的运营日,逐个比对(避免逐条查询)
// 分批写:每批一个事务(`US-07` AC4),整份写完才刷投影、才记终态(`INV-9`)。
// 覆盖范围内缺席的航班在最后清扫(`INV-7`)。
val upserted = commit.commitBatched(head) { batches ->
// 一次批量查出这些航班现有的运营日,逐个比对(避免逐条查询)
// 这一比对必须在任何一批写入之前跑完:运营日冲突要整包拒绝、一条都不落(`INV-4`)。
val mains = flightState.findMainRows(ok.perRecordDay.keys)
ok.perRecordDay.forEach { (flid, day) ->
val existing = mains[flid] ?: return@forEach
@@ -112,33 +117,47 @@ class ScheduleProcessor(
}
}
var written = 0
val events = mutableListOf<MsgEvent>()
val projections = mutableListOf<FlightProjectionWrite>()
// 一次建索引,避免在航班级循环里反复线性扫描(大日计划下是 O(N²))。
val recordsByFlid = body.records.associateBy { it.flid }
ok.perRecordDay.forEach { (flid, day) ->
val record = recordsByFlid.getValue(flid)
val existingMain = mains[flid]
val keepDeleted = existingMain?.state == FlightState.DELETED
if (keepDeleted) flags.add(SnapshotFlag.SCHD_REVIVE_CONFLICT) // 日计划不会让已删除的航班复活
val current = if (existingMain != null) flightState.loadFullSnapshot(flid) else null
val next = FlightStateEngine.snapshotState(
current = current,
record = record,
operationDay = day,
keepDeleted = keepDeleted,
)
when (flightState.persistFullState(next, msgId = head.msgId, now = clock.instant())) {
PersistOutcome.DAY_GUARD_VIOLATION ->
throw ProtocolViolation("operation-day guard violated flid=$flid")
else -> written++
var written = 0
ok.perRecordDay.entries.chunked(batchSize).forEach { chunk ->
written += batches.batch {
val events = mutableListOf<MsgEvent>()
val projections = mutableListOf<FlightProjectionWrite>()
var batchWritten = 0
chunk.forEach { (flid, day) ->
val record = recordsByFlid.getValue(flid)
val existingMain = mains[flid]
val keepDeleted = existingMain?.state == FlightState.DELETED
if (keepDeleted) flags.add(SnapshotFlag.SCHD_REVIVE_CONFLICT) // 日计划不会让已删除的航班复活
val next = FlightStateEngine.snapshotState(
current = existingMain,
record = record,
operationDay = day,
keepDeleted = keepDeleted,
)
when (flightState.persistFullState(next, msgId = head.msgId, now = clock.instant())) {
PersistOutcome.DAY_GUARD_VIOLATION ->
throw ProtocolViolation("operation-day guard violated flid=$flid")
else -> batchWritten++
}
events += eventsFor(next, head.msgId, mapper, clock.instant())
projections += projectionOf(next, mapper)
}
if (events.isNotEmpty()) msgEvents.insertAll(events)
DomainOutcome(batchWritten, projections)
}
events += eventsFor(next, head.msgId, mapper, clock.instant())
projections += projectionOf(next, mapper)
}
if (events.isNotEmpty()) msgEvents.insertAll(events)
DomainOutcome(written, projections)
sweepAbsent(
head = head,
batches = batches,
coverage = ok.perRecordDay.values.toSet(),
present = ok.perRecordDay.keys,
batchSize = batchSize,
flags = flags,
)
written
}
logSnapshot(head, body, SnapshotResult.COMMITTED, upserted, flags, started)
ApplyResult.Succeeded
@@ -148,6 +167,41 @@ class ScheduleProcessor(
}
}
/**
* 覆盖范围内缺席的航班:标删、登记删除通知、从投影里删掉(`INV-7`、`US-07` AC2/AC5)。
*
* [coverage] 是这份报文覆盖的运营日,取自报文自身——每条记录的 `SODT` 推出的运营日
* (整包校验已保证每条都算得出)。覆盖范围外的航班一条都不碰:前一日延误的航班不在
* 今天的日计划窗口里,不能因为缺席就被判为已删除。覆盖窗口的边界口径见 `Q19`。
*/
private fun sweepAbsent(
head: ProcState,
batches: FlightCommit.BatchScope,
coverage: Set<LocalDate>,
present: Set<String>,
batchSize: Int,
flags: MutableSet<SnapshotFlag>,
) {
val absent = flightState.findActiveByOperationDays(coverage).filter { it.flid !in present }
if (absent.isEmpty()) return
flags.add(SnapshotFlag.SCHD_ABSENT_DELETED)
absent.chunked(batchSize).forEach { chunk ->
batches.batch {
val events = mutableListOf<MsgEvent>()
val projections = mutableListOf<FlightProjectionWrite>()
chunk.forEach { main ->
if (!flightState.markDeleted(main.flid, msgId = head.msgId, now = clock.instant())) return@forEach
// markDeleted 把版本加一;候选是本次读到的 ACTIVE 行,整段持锁期间没有别的写者
val deletedVersion = main.stateVersion + 1
events += deleteEvent(main.flid, deletedVersion, head.msgId, mapper, clock.instant())
projections += FlightProjectionWrite.Delete(main.flid, deletedVersion)
}
if (events.isNotEmpty()) msgEvents.insertAll(events)
DomainOutcome(Unit, projections)
}
}
}
/**
* 写一条处理留痕(仅供排查和统计,不参与业务判断)。放在事务外做,
* 写失败也只记日志,不会连累处理结果。scope 是这份报文覆盖的运营日范围。