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:
@@ -75,21 +75,7 @@ class FdelProcessor(
|
||||
// 重处理开始时已把上一轮没放行的事件清掉,这里不补登就会把这条通知丢了。
|
||||
if (deleted || (main.state == FlightState.DELETED && main.lastMsgId == head.msgId)) {
|
||||
msgEvents.insertAll(
|
||||
listOf(
|
||||
// C-9:删除通知只走 KAFKA:msg,schd 不再发 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 是这份报文覆盖的运营日范围。
|
||||
|
||||
Reference in New Issue
Block a user