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
@@ -107,6 +107,12 @@ class PipelineProps {
class Schd {
var flushPeriod: Duration = Duration.ofSeconds(3) // KEEP 现役节律
var flushLimit: Int = 500
/**
* 一包日计划分多少个航班一批写库,每批一个事务(`US-07` AC4)。
* 缺席清扫(`INV-7`)按同一上限分批。整份写完才刷投影、才记终态(`INV-9`)。
*/
var snapshotBatch: Int = 200
}
@ConfigurationProperties("identity")
@@ -17,9 +17,10 @@ enum class SnapshotResult { COMMITTED, REPLAY_SKIPPED, ROLLED_BACK }
* EMPTY——空快照,合法但不是常态;
* RECS_DROP——报头声明的条数和实际收到的对不上;
* DAY_MISMATCH——有记录算不出运营日,整包拒收;
* SCHD_REVIVE_CONFLICT——日计划打到了已删除的航班上,航班保持删除不恢复
* SCHD_REVIVE_CONFLICT——日计划打到了已删除的航班上,航班保持删除不恢复
* SCHD_ABSENT_DELETED——覆盖范围内有航班在快照里缺席,已按缺席标删(`INV-7`)。
*/
enum class SnapshotFlag { EMPTY, RECS_DROP, DAY_MISMATCH, SCHD_REVIVE_CONFLICT }
enum class SnapshotFlag { EMPTY, RECS_DROP, DAY_MISMATCH, SCHD_REVIVE_CONFLICT, SCHD_ABSENT_DELETED }
data class SnapshotLogEntry(
val msgId: Long,
@@ -8,8 +8,8 @@ import java.time.LocalDate
* 航班状态引擎:把报文内容和库里已有的状态合成出新状态,全部在内存里算,不碰数据库和
* Kafka,方便单独测。三个入口:
* - [validateMessage]:整包校验日计划,检查声明条数、航班标识、运营日能不能算出来;
* - [snapshotState]:把日计划(DNLD/RESP合并进当前态——报文里出现的字段覆盖本地值
* 没出现的保留,标量给空串表示清空;已删除的航班保持删除,日计划救不回来;
* - [snapshotState]:把日计划(DNLD/RESP写成新的当前态——报文带的字段写进去
* 没带的字段和集合清掉;已删除的航班保持删除,日计划救不回来;
* - [mergedState]:把 FLOP/ADFT 的增量合并进当前态,只改报文明确表达的字段和集合。
*
* 合并规则见 docs/implementation.md「SCHD 日计划」「动态运行事件」。
@@ -90,17 +90,18 @@ object FlightStateEngine {
}
/**
* 把一条日计划记录合并进当前态:报文里出现的标量覆盖本地值(给空串表示显式清空)
* 没出现的保留;集合一旦出现就整体替换,没出现就保留。
*
* 新航班(current == null)没有本地值可保留,所以集合按全部键输出空集,保证落库时
* 每张明细表都有确定的行集。
* 把一条日计划记录写成新的当前态:**以 AODB 下发的这份快照为准**——报文带的字段写进去
* 没带的字段和集合一律清掉(`C-6`、`US-07` AC3)。日计划是覆盖范围内的完整列表,
* 不是增量,所以没有"没出现就保留"这回事(那是 FLOP/ADFT 的语义,见 [mergedState])。
*
* keepDeleted = true 时即使收到日计划也保持 DELETED:日计划不能把删掉的航班救回来,
* 唯一的恢复入口是 ADFT。调用方负责记一条 SCHD_REVIVE_CONFLICT 告警。
*
* [current] 只用来接上状态与版本,所以取主行就够:没带的字段既然要清掉,
* 读回全部明细也没有用处(一包日计划几千条,逐条读明细是白搭的开销)。
*/
fun snapshotState(
current: FlightSnapshot?,
current: FlightMainRow?,
record: ScheduleRecord,
operationDay: LocalDate,
keepDeleted: Boolean,
@@ -110,19 +111,10 @@ object FlightStateEngine {
keepDeleted -> FlightState.DELETED
else -> current.state
}
val scalars = buildMap {
current?.scalars?.let(::putAll) // 报文没带的标量保持原值
putAll(record.scalars) // 出现 = Set;空串 = 显式清空
}
val collections = buildMap {
if (current == null) {
COLLECTION_KEYS.forEach { key -> put(key, emptyList()) } // 新航班全键输出
} else {
putAll(current.collections) // 报文没带的集合保持原值
}
record.collections.forEach { (key, items) ->
if (key in COLLECTION_KEYS) put(key, normalizeCollection(key, items))
}
val scalars = record.scalars
// 全键输出:没带的集合落成空集,明细表因此按"先删后插"清空,不留上一份快照的行
val collections = COLLECTION_KEYS.associateWith { key ->
record.collections[key]?.let { normalizeCollection(key, it) } ?: emptyList()
}
return FlightSnapshot(
flid = record.flid,
@@ -249,6 +249,12 @@ interface FlightStateRepository {
fun findMainRows(flids: Collection<String>): Map<String, FlightMainRow>
/**
* 查这些运营日下仍在用的航班主行,供日计划做缺席清扫:快照覆盖范围内、报文里没出现的
* 航班要标删(`INV-7`)。运营日为空的航班(尚未被日计划收录)不在任何覆盖范围内,不返回。
*/
fun findActiveByOperationDays(operationDays: Collection<LocalDate>): List<FlightMainRow>
/** 读一条航班的完整当前态(主行加全部明细)。要读得一致,得由调用方放在事务里读。 */
fun loadFullSnapshot(flid: String): FlightSnapshot?
@@ -632,6 +632,17 @@ class JdbcFlightStateRepository(
return out
}
override fun findActiveByOperationDays(operationDays: Collection<LocalDate>): List<FlightMainRow> {
if (operationDays.isEmpty()) return emptyList()
val days = operationDays.distinct()
val placeholders = days.joinToString(",") { "?" }
return ds.query(
SELECT_MAIN + " WHERE state = 'ACTIVE' AND operation_day IN ($placeholders) ORDER BY flid",
{ ps -> days.forEachIndexed { i, day -> ps.setDate(i + 1, java.sql.Date.valueOf(day)) } },
::mapMainRow,
)
}
override fun loadFullSnapshot(flid: String): FlightSnapshot? {
val main = findMainRow(flid) ?: return null
val scalars = ds.queryOne(
@@ -28,6 +28,10 @@ class RedisFlightProjectionPort(
)
}
override fun readAll(): List<String> = throw UnsupportedOperationException(
"redis client not wired yet (G-REDIS-PROJECTION); key=${props.flightKey}",
)
override fun ping(): Boolean = false
}
@@ -46,4 +50,9 @@ class NoopFlightProjectionPort : FlightProjectionPort {
override fun write(writes: List<FlightProjectionWrite>) {
log.debug("redis projection disabled, skipping {} write(s) [G-REDIS-PROJECTION]", writes.size)
}
/** 没有投影可读时报错而不是回空列表:空列表会被查询方当成"现在没有航班"`INV-11`)。 */
override fun readAll(): List<String> = throw UnsupportedOperationException(
"redis projection disabled (msgx.redis.enabled=false)",
)
}
@@ -39,8 +39,8 @@ class StubDeliveryPort : DeliveryPort {
* 航班查询投影的内存假实现,只有配置 msgx.stubs=true 时才装配。
*
* [snapshots] 是投影的当前内容(FLID → 整态 JSON),删除会把条目移走,行为对齐 Redis 哈希。
* [failWith] 用来做故障注入:置上以后每次写都抛这个异常,用于验证"投影写不成功就不算处理完成"
* `INV-10`);置回 null 即恢复。
* [failWith] 用来做故障注入:置上以后每次写都抛这个异常,用于验证"投影写不成功就不算处理完成"
* `INV-10`与"读不到就报错、不回空列表"`INV-11`;置回 null 即恢复。
*/
@Requires(property = "msgx.stubs", value = "true")
@Singleton
@@ -55,6 +55,11 @@ class StubFlightProjectionPort : FlightProjectionPort {
snapshots.clear(); writes.clear(); failWith = null
}
override fun readAll(): List<String> {
failWith?.let { throw it }
return snapshots.values.toList()
}
override fun write(writes: List<FlightProjectionWrite>) {
failWith?.let { throw it }
this.writes += writes
@@ -365,6 +365,14 @@ class StubFlightState : FlightStateRepository {
override fun findMainRows(flids: Collection<String>): Map<String, FlightMainRow> =
flids.mapNotNull { flid -> mains[flid]?.let { flid to it } }.toMap()
override fun findActiveByOperationDays(operationDays: Collection<LocalDate>): List<FlightMainRow> {
if (operationDays.isEmpty()) return emptyList()
val days = operationDays.toSet()
return mains.values
.filter { it.state == FlightState.ACTIVE && it.operationDay in days }
.sortedBy { it.flid }
}
override fun loadFullSnapshot(flid: String): FlightSnapshot? = snapshots[flid]
/** 运营日不可变:库里已有运营日且与新值不同时,返回 DAY_GUARD_VIOLATION。 */
@@ -0,0 +1,53 @@
package com.gzzn.omms.msgexchange.ingress
import com.fasterxml.jackson.databind.ObjectMapper
import com.gzzn.omms.msgexchange.processing.FlightProjectionPort
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.MediaType
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.Produces
/**
* `GET /all/flights`:从 Redis 查询投影读当前全部动态航班,不含共享航班(`US-12`、`C-11`)。
*
* 数据只从投影来,不回落 PG:查询与网页客户端必须读同一份,不然两边会看到不同的航班
* (`INV-11`)。投影读不出来就返回错误——空列表会被前端当成"现在没有航班"`US-12` AC2)。
*
* 响应口径暂定(`Q21`):成功是裸 JSON 数组、不套旧系统的 `ResponseDto`;失败是 503 加一个
* 错误对象。投影载荷的形状与 `KAFKA:schd` 同一份,尚未按旧系统 `SCHD.FLTR` 逐字对拍。
*/
@Controller("/all/flights")
class FlightQueryController(
private val projection: FlightProjectionPort,
private val mapper: ObjectMapper,
) {
private val log = org.slf4j.LoggerFactory.getLogger(FlightQueryController::class.java)
@Get
@Produces(MediaType.APPLICATION_JSON)
fun all(): HttpResponse<String> =
try {
HttpResponse.ok(mapper.writeValueAsString(nonSharedFlights()))
} catch (e: Exception) {
// 读投影失败(Redis 不可用、未接通、载荷读不动)一律报错:这里没有可信的兜底数据源
val reason = e.message ?: e.javaClass.simpleName
log.error("GET /all/flights unavailable reason={}", reason, e)
HttpResponse.status<String>(HttpStatus.SERVICE_UNAVAILABLE)
.body(mapper.writeValueAsString(mapOf("error" to PROJECTION_UNAVAILABLE, "reason" to reason)))
}
/**
* 共享航班由 `MAID`(主航班 FLID)非空标识,随主航班下发,不单独出现在列表里
* `US-06` AC2 的主/共享关系,见 docs/implementation.md「航班域」)。
*/
private fun nonSharedFlights(): List<com.fasterxml.jackson.databind.JsonNode> =
projection.readAll()
.map { mapper.readTree(it) }
.filter { it.path("scalars").path("MAID").asText("").isBlank() }
private companion object {
private const val PROJECTION_UNAVAILABLE = "FLIGHT_PROJECTION_UNAVAILABLE"
}
}
@@ -22,5 +22,5 @@ class InboxController(private val inbox: InboxService) {
return HttpResponse.ok(receipt.msgId.toString()) // TODO: 先返回消息 ID 文本,等跟现役响应体逐字对拍过再定稿
}
// TODO(阶段2): 按契约清单补齐 /schd/sync、/all/flights、/kafka/topics/{name}/msgs 这个接口。
// TODO(阶段2): 按契约清单补齐 /schd/sync、/kafka/topics/{name}/msgs 这个接口。
}
@@ -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 是这份报文覆盖的运营日范围。