refactor(ingress): 信箱边界层契约重构——发现与处理标记解耦、回填事实并入 PROC_STATE
收报扫描不再以 DATE_PROCESSED 为谓词:终态而未回填的行(解码失败死信等)会永久占据 有限批次,累积到 claim-batch 后收报整体停摆(US-01 条目 3 / message-lifecycle §5.3)。 ingress/InboxPoller.kt:按 ID 区间升序有界读取(ID > W),水位落 INBOX_CURSOR 并与入队 同一 PG 事务推进(中断后重扫补建);遇空洞即停,空洞超过 pipeline.max-commit-delay 判定 为永久并放行——否则水位永久停摆于一次自增回滚留下的空位。删除 InboxEnqueue(改由 insertIfAbsent 幂等入队)与 poller 内的回填扫描(消除 ingress→jobs 反向依赖)。 infra/persistence:端口按事实重画为 readRange/maxId/markProcessedIfUnmarked,标记 UPDATE 带 DATE_PROCESSED IS NULL 守卫,只把空标写为已处理(§11 单调,重复执行无副作用)。 回填事实并入 PROC_STATE(RECEIVED_AT/BACKFILL_AT/NEXT_AT/ATTEMPTS/ERROR),BACKFILL_TODO 随 V2 迁移下线;终态与回填意图是同一条 UPDATE,由处理器在自己的业务事务内落库, message-lifecycle §4 登记的两个崩溃窗口(提交后回填前崩溃、待办二次落账失败)不再是缺口。 processing/BackfillService.kt(取代 BackfillSweepJob):终态提交后立即尝试一次,失败按 30s→15min 指数退避重试;扫描条件「终态 + 未确认标记 +(已到期 或 接收时间早于 NOW − R)」 使 §5.2 的超期期限 R 覆盖退避,中间态永不补写。死信同样可补写——回填只需消息 ID, 不再依赖 META。Pump 改用可注入 Clock。 infra/health:InboxLifecycleHealthIndicator 输出积压条数、最老未处理信龄、未回填终态数与 水位滞后(OPS-2 / §5.3 验收)。预计消化时长需吞吐采样,留待接入指标注册表时补。 配置:pipeline.max-commit-delay / overdue-backfill / backfill-batch、mailbox.processed-value (Q2/Q6/Q7 未书面确认前取保守初值,不得为提速下调)。 不变量回归测试:死信不阻断后续发现、水位遇空洞即停与老化放行、终态+意图同事务、 超期 R 覆盖退避、中间态不补写、标记单调;InboxLifecycleJdbcSqlTest 以 H2 的 PostgreSQL 兼容模式直连验证上述 SQL 语义(不依赖 docker)。libs.h2 由 testRuntimeOnly 提为 testImplementation 以支持该用例。 验证:gradle clean test --offline → 78 tests / 0 failures / 1 skipped (PG Testcontainers 集成用例在本机无 docker 时按既有约定 assumeTrue 跳过)。
This commit is contained in:
+3
-1
@@ -59,7 +59,9 @@ dependencies {
|
|||||||
runtimeOnly(libs.snakeyaml) // U02:显式版本入 catalog(原无版本号依赖 BOM 覆盖,snakeyaml 不在 micronaut BOM 内)
|
runtimeOnly(libs.snakeyaml) // U02:显式版本入 catalog(原无版本号依赖 BOM 覆盖,snakeyaml 不在 micronaut BOM 内)
|
||||||
|
|
||||||
testImplementation(libs.junit.jupiter)
|
testImplementation(libs.junit.jupiter)
|
||||||
testRuntimeOnly(libs.h2) // U03:配置绑定回归测试的测试库(内存 datasource,冒烟用)
|
// U03:配置绑定回归测试的测试库(内存 datasource,冒烟用);
|
||||||
|
// V2 起 InboxLifecycleJdbcSqlTest 以 H2 的 PostgreSQL 兼容模式直连验证生命周期 SQL 语义
|
||||||
|
testImplementation(libs.h2)
|
||||||
testImplementation("io.micronaut.test:micronaut-test-junit5")
|
testImplementation("io.micronaut.test:micronaut-test-junit5")
|
||||||
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
||||||
// P3-A(ACM2-29):无外部 PG 时 JDBC 集成测试自动起隔离容器;有 MSGX_PG_* 环境变量时不启用
|
// P3-A(ACM2-29):无外部 PG 时 JDBC 集成测试自动起隔离容器;有 MSGX_PG_* 环境变量时不启用
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import io.micronaut.context.annotation.ConfigurationProperties
|
|||||||
|
|
||||||
@ConfigurationProperties("mailbox")
|
@ConfigurationProperties("mailbox")
|
||||||
class MailboxProps {
|
class MailboxProps {
|
||||||
|
/**
|
||||||
|
* 处理标记写入值(message-lifecycle §5.2/Q7):仅限库方认可的 legacy 值集;
|
||||||
|
* 值集与写权限书面确认前保持 legacy 现役值。
|
||||||
|
*/
|
||||||
|
var processedValue: String = "PROCESSED"
|
||||||
|
|
||||||
var sharedMysql: SharedMysql = SharedMysql()
|
var sharedMysql: SharedMysql = SharedMysql()
|
||||||
|
|
||||||
@ConfigurationProperties("shared-mysql")
|
@ConfigurationProperties("shared-mysql")
|
||||||
|
|||||||
@@ -25,6 +25,22 @@ class PipelineProps {
|
|||||||
var backoffCapMs: Long = 60_000
|
var backoffCapMs: Long = 60_000
|
||||||
var headDeadline: Duration = Duration.ofMinutes(10) // 最坏 HOL 上界(毒丸升级)
|
var headDeadline: Duration = Duration.ofMinutes(10) // 最坏 HOL 上界(毒丸升级)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* message-lifecycle §5.1 空洞老化:水位 W+1 处的空洞持续超过该时延即判定为永久并放行。
|
||||||
|
* 取值口径 = 库方承诺的最大提交时延(Q2);太小会把迟到消息判成永久空洞(FIFO 越序风险)。
|
||||||
|
*/
|
||||||
|
var maxCommitDelay: Duration = Duration.ofMinutes(5)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* message-lifecycle §5.2 超期补写期限 R:终态后仍无处理标记的行到达该期限即强制补写,
|
||||||
|
* 保证库方清除前提「边界内无未标记行」在有限时间内成立。
|
||||||
|
* R ≥ 人工重放期限 + 人工处置期限(Q6);确认前不得下调。
|
||||||
|
*/
|
||||||
|
var overdueBackfill: Duration = Duration.ofDays(30)
|
||||||
|
|
||||||
|
/** 回填扫描单批条数。 */
|
||||||
|
var backfillBatch: Int = 100
|
||||||
|
|
||||||
/** U07:启动即拉起 Pump/Dispatcher 循环(默认关——需要真实仓储或 msgx.stubs=true 才可安全开启)。 */
|
/** U07:启动即拉起 Pump/Dispatcher 循环(默认关——需要真实仓储或 msgx.stubs=true 才可安全开启)。 */
|
||||||
var autostart: Boolean = false
|
var autostart: Boolean = false
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
package com.gzzn.omms.msgexchange.domain
|
package com.gzzn.omms.msgexchange.domain
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PROC_STATE 处理伴生状态(docs/design.md §2.1/§2.3):每消息一行,
|
* PROC_STATE 处理伴生状态(docs/design.md §2.1/§2.3):每消息一行,
|
||||||
* MSG_ID = 信箱 ID 主键防重复入队;IDENTITY_KEY 唯一约束防业务重复;
|
* MSG_ID = 信箱 ID 主键防重复入队;IDENTITY_KEY 唯一约束防业务重复;
|
||||||
* 处理状态机与错误分类同设计文档 §2.3。SUCCEEDED 终态兼作 SCHD 快照
|
* 处理状态机与错误分类同设计文档 §2.3。SUCCEEDED 终态兼作 SCHD 快照
|
||||||
* 重放判定(docs/flight-state.md §4 步骤 / design.md §4.1)。
|
* 重放判定(docs/flight-state.md §4 步骤 / design.md §4.1)。
|
||||||
|
*
|
||||||
|
* 回填事实(message-lifecycle.md §5.2/§11)与处理事实同体同行:终态与回填意图
|
||||||
|
* 由同一条 UPDATE 落下,因此不存在"业务已提交、回填待办未记"的崩溃窗口。
|
||||||
*/
|
*/
|
||||||
enum class ProcStatus { PENDING, FAILED, SUCCEEDED, SKIPPED, DEAD }
|
enum class ProcStatus { PENDING, FAILED, SUCCEEDED, SKIPPED, DEAD }
|
||||||
|
|
||||||
@@ -16,8 +21,16 @@ data class ProcState(
|
|||||||
val state: ProcStatus,
|
val state: ProcStatus,
|
||||||
val identityKey: String? = null, // SNDR|TYPE|STYP|SEQN(design.md §2.2);decode 后首次绑定,FAILED 重试不重绑
|
val identityKey: String? = null, // SNDR|TYPE|STYP|SEQN(design.md §2.2);decode 后首次绑定,FAILED 重试不重绑
|
||||||
val attempts: Int = 0,
|
val attempts: Int = 0,
|
||||||
val nextAttemptAt: java.time.Instant? = null,
|
val nextAttemptAt: Instant? = null,
|
||||||
val errorClass: ErrorClass? = null,
|
val errorClass: ErrorClass? = null,
|
||||||
val lastError: String? = null,
|
val lastError: String? = null,
|
||||||
val updatedAt: java.time.Instant = java.time.Instant.now(),
|
/** 信箱 CMINMSGS_DATE_RECEIVED:§5.2 超期补写的 R 判据与 OPS-2「最老未处理信龄」锚点。 */
|
||||||
|
val receivedAt: Instant? = null,
|
||||||
|
/** 非空 = 已确认信箱行持有处理标记(回填完成)。 */
|
||||||
|
val backfillAt: Instant? = null,
|
||||||
|
/** 非空 = 待回填;终态事务内登记为 now,失败按退避推后。 */
|
||||||
|
val backfillNextAt: Instant? = null,
|
||||||
|
val backfillAttempts: Int = 0,
|
||||||
|
val backfillError: String? = null,
|
||||||
|
val updatedAt: Instant = Instant.now(),
|
||||||
)
|
)
|
||||||
|
|||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
package com.gzzn.omms.msgexchange.infra.health
|
||||||
|
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.InboxCursorRepository
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
|
||||||
|
import io.micronaut.context.BeanProvider
|
||||||
|
import io.micronaut.core.async.publisher.Publishers
|
||||||
|
import io.micronaut.health.HealthStatus
|
||||||
|
import io.micronaut.management.health.indicator.HealthIndicator
|
||||||
|
import io.micronaut.management.health.indicator.HealthResult
|
||||||
|
import jakarta.inject.Singleton
|
||||||
|
import org.reactivestreams.Publisher
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 信箱生命周期观测(docs/message-lifecycle.md §5.3 验收 / user-stories.md OPS-2):
|
||||||
|
* 输出剩余积压、最老未处理信龄、未回填终态条数与水位滞后,供积压消化期间持续观察。
|
||||||
|
*
|
||||||
|
* 端口缺省(未接通共享信箱或自有 PG)时报告未绑定而不判 DOWN——可用性由各依赖自身的
|
||||||
|
* 健康指示器承担,本指示器只反映生命周期状态;端口查询失败判 DOWN。
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class InboxLifecycleHealthIndicator(
|
||||||
|
private val procState: BeanProvider<ProcStateRepository>,
|
||||||
|
private val cursor: BeanProvider<InboxCursorRepository>,
|
||||||
|
private val mailbox: BeanProvider<CminmsgInboxRepository>,
|
||||||
|
) : HealthIndicator {
|
||||||
|
|
||||||
|
override fun getResult(): Publisher<HealthResult> =
|
||||||
|
Publishers.just(
|
||||||
|
runCatching {
|
||||||
|
lifecycleHealth(
|
||||||
|
procState = if (procState.isPresent) procState.get() else null,
|
||||||
|
cursor = if (cursor.isPresent) cursor.get() else null,
|
||||||
|
mailbox = if (mailbox.isPresent) mailbox.get() else null,
|
||||||
|
)
|
||||||
|
}.getOrElse { down(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun lifecycleHealth(
|
||||||
|
procState: ProcStateRepository?,
|
||||||
|
cursor: InboxCursorRepository?,
|
||||||
|
mailbox: CminmsgInboxRepository?,
|
||||||
|
now: Instant = Instant.now(),
|
||||||
|
): HealthResult {
|
||||||
|
if (procState == null) {
|
||||||
|
return HealthResult.builder(NAME).status(HealthStatus.UP)
|
||||||
|
.details(mapOf("message" to "proc_state repository not bound (stub off, impl pending)"))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
val backlog = procState.backlog()
|
||||||
|
val watermark = cursor?.load()?.committedUpTo
|
||||||
|
val maxId = runCatching { mailbox?.maxId() }.getOrNull()
|
||||||
|
return HealthResult.builder(NAME).status(HealthStatus.UP).details(
|
||||||
|
linkedMapOf<String, Any>(
|
||||||
|
"backlog" to backlog.unfinished,
|
||||||
|
"oldestUnprocessedSeconds" to (
|
||||||
|
backlog.oldestReceivedAt?.let { Duration.between(it, now).seconds } ?: -1L
|
||||||
|
),
|
||||||
|
"unmarkedTerminal" to backlog.unmarkedTerminal,
|
||||||
|
"watermark" to (watermark ?: -1L),
|
||||||
|
"watermarkLag" to if (watermark != null && maxId != null) maxId - watermark else -1L,
|
||||||
|
),
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun down(error: Throwable): HealthResult =
|
||||||
|
HealthResult.builder(NAME).status(HealthStatus.DOWN)
|
||||||
|
.details(mapOf("message" to (error.message ?: error.javaClass.simpleName)))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private const val NAME = "inbox-lifecycle"
|
||||||
@@ -29,12 +29,17 @@ interface PipelineLockRepository {
|
|||||||
fun lock()
|
fun lock()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PROC_STATE:每消息一行(design.md §2.1);SUCCEEDED 终态兼作日计划重放判定。 */
|
/**
|
||||||
|
* PROC_STATE:每消息一行(design.md §2.1);SUCCEEDED 终态兼作日计划重放判定。
|
||||||
|
* 回填事实(BACKFILL_AT/NEXT_AT/ATTEMPTS/ERROR)与处理事实同行,取代独立的回填待办表。
|
||||||
|
*/
|
||||||
interface ProcStateRepository {
|
interface ProcStateRepository {
|
||||||
fun insert(msgId: Long, state: ProcStatus = ProcStatus.PENDING)
|
/**
|
||||||
|
* 入队:MSG_ID 主键幂等(重复扫描与 compat 入口并发都不会重复建行)。
|
||||||
/** 主路径轮询/compat 入队前判重(PG 已有行则跳过)。 */
|
* @param receivedAt 信箱 DATE_RECEIVED,用于 §5.2 超期判据与信龄观测。
|
||||||
fun exists(msgId: Long): Boolean
|
* @return true = 本次实际新建
|
||||||
|
*/
|
||||||
|
fun insertIfAbsent(msgId: Long, receivedAt: Instant?): Boolean
|
||||||
|
|
||||||
fun find(msgId: Long): ProcState?
|
fun find(msgId: Long): ProcState?
|
||||||
|
|
||||||
@@ -48,6 +53,7 @@ interface ProcStateRepository {
|
|||||||
|
|
||||||
fun ownerOfIdentity(identityKey: String): Long?
|
fun ownerOfIdentity(identityKey: String): Long?
|
||||||
|
|
||||||
|
/** 非终态迁移(PENDING / FAILED 及退避),不触碰回填列。 */
|
||||||
fun update(
|
fun update(
|
||||||
msgId: Long,
|
msgId: Long,
|
||||||
state: ProcStatus,
|
state: ProcStatus,
|
||||||
@@ -57,10 +63,44 @@ interface ProcStateRepository {
|
|||||||
lastError: String? = null,
|
lastError: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 终态 + 回填意图同一条 UPDATE(message-lifecycle §2/§4),由处理器在自己的业务
|
||||||
|
* 事务内调用:航班变更、事件、终态、回填意图同提交同回滚。
|
||||||
|
*/
|
||||||
|
fun markTerminal(
|
||||||
|
msgId: Long,
|
||||||
|
state: ProcStatus,
|
||||||
|
errorClass: ErrorClass? = null,
|
||||||
|
lastError: String? = null,
|
||||||
|
attempts: Int? = null,
|
||||||
|
now: Instant = Instant.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 信箱行已确认持有处理标记。 */
|
||||||
|
fun markBackfilled(msgId: Long, now: Instant = Instant.now())
|
||||||
|
|
||||||
|
/** 回填失败:次数 +1、按退避推后、留错误;终态不得回改(§11)。 */
|
||||||
|
fun recordBackfillFailure(msgId: Long, error: String?, attempts: Int, nextAttemptAt: Instant, now: Instant)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待回填待办(message-lifecycle §3/§5.2):终态 + 未确认标记,
|
||||||
|
* 且(已到期 或 接收时间已达超期期限 overdueBefore)。
|
||||||
|
*/
|
||||||
|
fun findBackfillDue(now: Instant, overdueBefore: Instant, limit: Int): List<BackfillDue>
|
||||||
|
|
||||||
/** 显式重放入口:仅把给定 errorClass 集合中的行从 FAILED/DEAD 置回 PENDING(ATTEMPTS=0)。 */
|
/** 显式重放入口:仅把给定 errorClass 集合中的行从 FAILED/DEAD 置回 PENDING(ATTEMPTS=0)。 */
|
||||||
fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int
|
fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int
|
||||||
|
|
||||||
|
/** OPS-2 观测口径(message-lifecycle §5.3 验收):积压、最老信龄锚点、未回填终态。 */
|
||||||
|
fun backlog(): Backlog
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 某条待回填记录(扫描输入)。 */
|
||||||
|
data class BackfillDue(val msgId: Long, val attempts: Int)
|
||||||
|
|
||||||
|
/** 处理侧积压快照。 */
|
||||||
|
data class Backlog(val unfinished: Int, val oldestReceivedAt: Instant?, val unmarkedTerminal: Int)
|
||||||
|
|
||||||
/** MSG_EVENT outbox(flight-state.md §5)。KAFKA_SCHD 合并同 FLID 未发事件按最新 STATE_VERSION 输出。 */
|
/** MSG_EVENT outbox(flight-state.md §5)。KAFKA_SCHD 合并同 FLID 未发事件按最新 STATE_VERSION 输出。 */
|
||||||
interface MsgEventRepository {
|
interface MsgEventRepository {
|
||||||
fun insertAll(events: List<MsgEvent>): List<Long>
|
fun insertAll(events: List<MsgEvent>): List<Long>
|
||||||
@@ -168,41 +208,39 @@ interface ReqTrackRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 共享信箱回填补偿待办(design.md §3.3/§6.1):业务事务内预登记,
|
* 消费水位 W(message-lifecycle §5.1):单行游标,只随新 ID 成功入队推进、遇空洞即停,
|
||||||
* 提交后由 BackfillSweepJob 重试;回填失败不得把 SUCCEEDED 改回 FAILED。
|
* 与入队在同一 PG 事务提交——中断后 W 未前进,重扫即补建(§4 第一行)。
|
||||||
|
*
|
||||||
|
* `holeSince` 记录 W+1 处空洞首次被观测到的时刻:超过最大提交时延(Q2 承诺)即判定为
|
||||||
|
* 永久空洞并放行,否则水位会永久停摆于一次自增回滚留下的空位,后续 ID 再无入队机会。
|
||||||
*/
|
*/
|
||||||
interface BackfillTodoRepository {
|
interface InboxCursorRepository {
|
||||||
data class BackfillTask(
|
data class Cursor(val committedUpTo: Long = 0L, val holeSince: Instant? = null)
|
||||||
val msgId: Long,
|
|
||||||
val sndr: String,
|
|
||||||
val type: String,
|
|
||||||
val styp: String,
|
|
||||||
val seqn: Long,
|
|
||||||
val attempts: Int = 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 失败即落库(幂等 upsert,同 ID 重复失败只刷新错误与重试时间)。 */
|
fun load(): Cursor
|
||||||
fun record(task: BackfillTask, lastError: String?, now: Instant = Instant.now())
|
|
||||||
|
|
||||||
fun findDue(now: Instant = Instant.now(), limit: Int = 50): List<BackfillTask>
|
fun save(cursor: Cursor)
|
||||||
|
|
||||||
fun markFailed(msgId: Long, lastError: String?, nextAttemptAt: Instant, now: Instant = Instant.now())
|
|
||||||
|
|
||||||
fun delete(msgId: Long)
|
|
||||||
|
|
||||||
fun count(): Int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 共享 MySQL 信箱 CMINMSGS 访问(他人系统库,本系统不建表)。
|
* 共享 MySQL 信箱 CMINMSGS 访问(他人系统库,本系统不建表,只做 DML)。
|
||||||
* 主路径 JDBC 轮询读 + 处理回填;compat HTTP 写;出站写 COUTMSGS 由出站适配层承担。
|
* 三个事实互不替代(message-lifecycle §5.1/§11):**发现**按 ID 区间读、**水位**只表示
|
||||||
|
* 读取进度、**处理标记**只用于回填与库方清除——标记不得作为扫描谓词。
|
||||||
*/
|
*/
|
||||||
interface CminmsgInboxRepository {
|
interface CminmsgInboxRepository {
|
||||||
fun insertRaw(rawXml: String): Long
|
fun insertRaw(rawXml: String): Long
|
||||||
|
|
||||||
fun rawOf(msgId: Long): String?
|
fun rawOf(msgId: Long): String?
|
||||||
|
|
||||||
fun pollUnprocessed(afterId: Long, limit: Int): List<Long>
|
/** 按 ID 区间升序有界读取(`ID > fromExclusive`),不以处理标记为谓词。 */
|
||||||
|
fun readRange(fromExclusive: Long, limit: Int): List<MailboxRow>
|
||||||
|
|
||||||
fun backfillOnSuccess(msgId: Long, sndr: String, type: String, styp: String, seqn: Long)
|
/** 信箱当前最大 ID(空表 null);仅用于观测水位滞后。 */
|
||||||
|
fun maxId(): Long?
|
||||||
|
|
||||||
|
/** 只把空标写为已处理(§11 单调);返回 true = 本次实际写入,重复执行无副作用。 */
|
||||||
|
fun markProcessedIfUnmarked(msgId: Long, value: String): Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 信箱行读取结果(发现阶段只需要身份与接收时间,原文按需再取)。 */
|
||||||
|
data class MailboxRow(val msgId: Long, val receivedAt: Instant?)
|
||||||
|
|||||||
+29
-11
@@ -1,14 +1,18 @@
|
|||||||
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
|
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
|
||||||
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.MailboxRow
|
||||||
import io.micronaut.context.annotation.Requires
|
import io.micronaut.context.annotation.Requires
|
||||||
import jakarta.inject.Named
|
import jakarta.inject.Named
|
||||||
import jakarta.inject.Singleton
|
import jakarta.inject.Singleton
|
||||||
import javax.sql.DataSource
|
import javax.sql.DataSource
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 共享 MySQL CMINMSGS 信箱适配(ACM2-12:仅 DML,不建表)。
|
* 共享 MySQL CMINMSGS 信箱适配(仅 DML,不建表;message-lifecycle §5.1/§6/§11)。
|
||||||
* 列名与 legacy `entity/Cminmsg.java` 一致。
|
* 列名与 legacy `entity/Cminmsg.java` 一致。
|
||||||
|
*
|
||||||
|
* 发现按 ID 区间读(`ID > ?`),**不以 `DATE_PROCESSED` 为扫描谓词**:已入队但尚未
|
||||||
|
* 回填的行否则会永久占据批次,正是 §5.1 与 US-01 条目 3 要求排除的场景。
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
@Requires(property = "msgx.stubs", notEquals = "true")
|
@Requires(property = "msgx.stubs", notEquals = "true")
|
||||||
@@ -33,31 +37,45 @@ class JdbcCminmsgInboxRepository(
|
|||||||
{ ps -> ps.setLong(1, msgId) },
|
{ ps -> ps.setLong(1, msgId) },
|
||||||
) { rs -> rs.getString("CMINMSGS_CLOB_MSG") }
|
) { rs -> rs.getString("CMINMSGS_CLOB_MSG") }
|
||||||
|
|
||||||
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> =
|
override fun readRange(fromExclusive: Long, limit: Int): List<MailboxRow> =
|
||||||
ds.query(
|
ds.query(
|
||||||
"""
|
"""
|
||||||
SELECT CMINMSGS_ID FROM cminmsgs
|
SELECT CMINMSGS_ID, CMINMSGS_DATE_RECEIVED FROM cminmsgs
|
||||||
WHERE CMINMSGS_ID > ? AND CMINMSGS_DATE_PROCESSED IS NULL
|
WHERE CMINMSGS_ID > ?
|
||||||
ORDER BY CMINMSGS_ID ASC
|
ORDER BY CMINMSGS_ID ASC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
{ ps ->
|
{ ps ->
|
||||||
ps.setLong(1, afterId)
|
ps.setLong(1, fromExclusive)
|
||||||
ps.setInt(2, limit)
|
ps.setInt(2, limit)
|
||||||
},
|
},
|
||||||
) { rs -> rs.getLong("CMINMSGS_ID") }
|
) { rs ->
|
||||||
|
MailboxRow(
|
||||||
|
msgId = rs.getLong("CMINMSGS_ID"),
|
||||||
|
receivedAt = rs.getTimestamp("CMINMSGS_DATE_RECEIVED")?.toInstant(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override fun backfillOnSuccess(msgId: Long, sndr: String, type: String, styp: String, seqn: Long) {
|
override fun maxId(): Long? =
|
||||||
|
ds.queryOne("SELECT MAX(CMINMSGS_ID) AS max_id FROM cminmsgs", {}) { rs ->
|
||||||
|
rs.getLong("max_id").takeIf { !rs.wasNull() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §11 单调:`DATE_PROCESSED IS NULL` 守卫保证只把空标写为已处理,已有值不回撤、
|
||||||
|
* 不覆盖;影响 0 行 = 已被其他路径标记,调用方按幂等成功处理。
|
||||||
|
* 写入值(DATE_PROCESSED 时间语义与 STATUS 值集)以库方契约为准(Q7)。
|
||||||
|
*/
|
||||||
|
override fun markProcessedIfUnmarked(msgId: Long, value: String): Boolean =
|
||||||
ds.update(
|
ds.update(
|
||||||
"""
|
"""
|
||||||
UPDATE cminmsgs
|
UPDATE cminmsgs
|
||||||
SET CMINMSGS_DATE_PROCESSED = CURRENT_TIMESTAMP,
|
SET CMINMSGS_DATE_PROCESSED = CURRENT_TIMESTAMP,
|
||||||
CMINMSGS_STATUS = ?
|
CMINMSGS_STATUS = ?
|
||||||
WHERE CMINMSGS_ID = ?
|
WHERE CMINMSGS_ID = ? AND CMINMSGS_DATE_PROCESSED IS NULL
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
) { ps ->
|
) { ps ->
|
||||||
ps.setString(1, "PROCESSED")
|
ps.setString(1, value)
|
||||||
ps.setLong(2, msgId)
|
ps.setLong(2, msgId)
|
||||||
}
|
} == 1
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+145
-76
@@ -11,9 +11,11 @@ import com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot
|
|||||||
import com.gzzn.omms.msgexchange.domain.flight.FlightState
|
import com.gzzn.omms.msgexchange.domain.flight.FlightState
|
||||||
import com.gzzn.omms.msgexchange.domain.flight.HistoryCandidate
|
import com.gzzn.omms.msgexchange.domain.flight.HistoryCandidate
|
||||||
import com.gzzn.omms.msgexchange.domain.flight.HistoryRules
|
import com.gzzn.omms.msgexchange.domain.flight.HistoryRules
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
|
import com.gzzn.omms.msgexchange.infra.persistence.BackfillDue
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.Backlog
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
|
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.InboxCursorRepository
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
|
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
|
import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository
|
import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository
|
||||||
@@ -68,22 +70,20 @@ class JdbcPipelineLockRepository(
|
|||||||
class JdbcProcStateRepository(
|
class JdbcProcStateRepository(
|
||||||
private val ds: DataSource,
|
private val ds: DataSource,
|
||||||
) : ProcStateRepository {
|
) : ProcStateRepository {
|
||||||
override fun insert(msgId: Long, state: ProcStatus) {
|
/** MSG_ID 主键幂等入队:重复扫描与 compat 入口并发都不重复建行(§5.1)。 */
|
||||||
|
override fun insertIfAbsent(msgId: Long, receivedAt: Instant?): Boolean =
|
||||||
ds.update(
|
ds.update(
|
||||||
"INSERT INTO proc_state (msg_id, state, updated_at) VALUES (?, ?, ?)",
|
"INSERT INTO proc_state (msg_id, state, received_at, updated_at) VALUES (?, 'PENDING', ?, ?) " +
|
||||||
{ ps -> ps.setLong(1, msgId); ps.setString(2, state.name); ps.setTimestamp(3, Instant.now().toSqlTimestamp()) },
|
"ON CONFLICT (msg_id) DO NOTHING",
|
||||||
)
|
{ ps ->
|
||||||
}
|
ps.setLong(1, msgId)
|
||||||
|
ps.setTimestamp(2, receivedAt?.toSqlTimestamp())
|
||||||
override fun exists(msgId: Long): Boolean =
|
ps.setTimestamp(3, Instant.now().toSqlTimestamp())
|
||||||
ds.queryOne("SELECT 1 FROM proc_state WHERE msg_id = ?", { ps -> ps.setLong(1, msgId) }) { 1 } != null
|
},
|
||||||
|
) == 1
|
||||||
|
|
||||||
override fun find(msgId: Long): ProcState? =
|
override fun find(msgId: Long): ProcState? =
|
||||||
ds.queryOne(
|
ds.queryOne("$SELECT_PROC WHERE msg_id = ?", { ps -> ps.setLong(1, msgId) }, ::mapProcState)
|
||||||
"SELECT msg_id, state, identity_key, attempts, next_attempt_at, error_class, last_error, updated_at FROM proc_state WHERE msg_id = ?",
|
|
||||||
{ ps -> ps.setLong(1, msgId) },
|
|
||||||
::mapProcState,
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun findSuccessTerminal(msgId: Long): Boolean =
|
override fun findSuccessTerminal(msgId: Long): Boolean =
|
||||||
ds.queryOne(
|
ds.queryOne(
|
||||||
@@ -93,8 +93,7 @@ class JdbcProcStateRepository(
|
|||||||
|
|
||||||
override fun headUnfinished(): ProcState? =
|
override fun headUnfinished(): ProcState? =
|
||||||
ds.queryOne(
|
ds.queryOne(
|
||||||
"SELECT msg_id, state, identity_key, attempts, next_attempt_at, error_class, last_error, updated_at " +
|
"$SELECT_PROC WHERE state IN ('PENDING', 'FAILED') ORDER BY msg_id ASC LIMIT 1",
|
||||||
"FROM proc_state WHERE state IN ('PENDING', 'FAILED') ORDER BY msg_id ASC LIMIT 1",
|
|
||||||
{},
|
{},
|
||||||
::mapProcState,
|
::mapProcState,
|
||||||
)
|
)
|
||||||
@@ -145,6 +144,80 @@ class JdbcProcStateRepository(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 终态与回填意图同一条 UPDATE:业务事务内调用即原子提交(message-lifecycle §2/§4)。 */
|
||||||
|
override fun markTerminal(
|
||||||
|
msgId: Long,
|
||||||
|
state: ProcStatus,
|
||||||
|
errorClass: ErrorClass?,
|
||||||
|
lastError: String?,
|
||||||
|
attempts: Int?,
|
||||||
|
now: Instant,
|
||||||
|
) {
|
||||||
|
ds.update(
|
||||||
|
"""
|
||||||
|
UPDATE proc_state
|
||||||
|
SET state = ?, error_class = ?, last_error = ?, attempts = COALESCE(?, attempts),
|
||||||
|
next_attempt_at = NULL,
|
||||||
|
backfill_at = NULL, backfill_next_at = ?, backfill_attempts = 0, backfill_error = NULL,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE msg_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
{ ps ->
|
||||||
|
ps.setString(1, state.name)
|
||||||
|
ps.setString(2, errorClass?.name)
|
||||||
|
ps.setString(3, lastError?.take(1000))
|
||||||
|
attempts?.let { ps.setInt(4, it) } ?: ps.setNull(4, java.sql.Types.INTEGER)
|
||||||
|
ps.setTimestamp(5, now.toSqlTimestamp())
|
||||||
|
ps.setTimestamp(6, now.toSqlTimestamp())
|
||||||
|
ps.setLong(7, msgId)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun markBackfilled(msgId: Long, now: Instant) {
|
||||||
|
ds.update(
|
||||||
|
"UPDATE proc_state SET backfill_at = ?, backfill_next_at = NULL, backfill_error = NULL, updated_at = ? WHERE msg_id = ?",
|
||||||
|
{ ps ->
|
||||||
|
ps.setTimestamp(1, now.toSqlTimestamp())
|
||||||
|
ps.setTimestamp(2, now.toSqlTimestamp())
|
||||||
|
ps.setLong(3, msgId)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun recordBackfillFailure(msgId: Long, error: String?, attempts: Int, nextAttemptAt: Instant, now: Instant) {
|
||||||
|
ds.update(
|
||||||
|
"UPDATE proc_state SET backfill_attempts = ?, backfill_next_at = ?, backfill_error = ?, updated_at = ? WHERE msg_id = ?",
|
||||||
|
{ ps ->
|
||||||
|
ps.setInt(1, attempts)
|
||||||
|
ps.setTimestamp(2, nextAttemptAt.toSqlTimestamp())
|
||||||
|
ps.setString(3, error?.take(512))
|
||||||
|
ps.setTimestamp(4, now.toSqlTimestamp())
|
||||||
|
ps.setLong(5, msgId)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待回填:终态 + 未确认标记,且已到期或已达 §5.2 超期期限(R 覆盖退避,保证
|
||||||
|
* 有限时间内必然补写,否则库方清除的前提"边界内无未标记行"无法成立)。
|
||||||
|
*/
|
||||||
|
override fun findBackfillDue(now: Instant, overdueBefore: Instant, limit: Int): List<BackfillDue> =
|
||||||
|
ds.query(
|
||||||
|
"""
|
||||||
|
SELECT msg_id, backfill_attempts FROM proc_state
|
||||||
|
WHERE backfill_at IS NULL
|
||||||
|
AND state IN ('SUCCEEDED', 'SKIPPED', 'DEAD')
|
||||||
|
AND (backfill_next_at IS NULL OR backfill_next_at <= ? OR (received_at IS NOT NULL AND received_at < ?))
|
||||||
|
ORDER BY msg_id ASC LIMIT ?
|
||||||
|
""".trimIndent(),
|
||||||
|
{ ps ->
|
||||||
|
ps.setTimestamp(1, now.toSqlTimestamp())
|
||||||
|
ps.setTimestamp(2, overdueBefore.toSqlTimestamp())
|
||||||
|
ps.setInt(3, limit)
|
||||||
|
},
|
||||||
|
) { rs -> BackfillDue(rs.getLong("msg_id"), rs.getInt("backfill_attempts")) }
|
||||||
|
|
||||||
override fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int {
|
override fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int {
|
||||||
if (errorClasses.isEmpty()) return 0
|
if (errorClasses.isEmpty()) return 0
|
||||||
val placeholders = errorClasses.joinToString(",") { "?" }
|
val placeholders = errorClasses.joinToString(",") { "?" }
|
||||||
@@ -158,6 +231,25 @@ class JdbcProcStateRepository(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** OPS-2(message-lifecycle §5.3):积压条数、最老未处理接收时刻、未回填终态条数。 */
|
||||||
|
override fun backlog(): Backlog =
|
||||||
|
ds.queryOne(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
count(*) FILTER (WHERE state IN ('PENDING', 'FAILED')) AS unfinished,
|
||||||
|
min(received_at) FILTER (WHERE state IN ('PENDING', 'FAILED')) AS oldest_received_at,
|
||||||
|
count(*) FILTER (WHERE state IN ('SUCCEEDED', 'SKIPPED', 'DEAD') AND backfill_at IS NULL) AS unmarked_terminal
|
||||||
|
FROM proc_state
|
||||||
|
""".trimIndent(),
|
||||||
|
{},
|
||||||
|
) { rs ->
|
||||||
|
Backlog(
|
||||||
|
unfinished = rs.getInt("unfinished"),
|
||||||
|
oldestReceivedAt = rs.getInstant("oldest_received_at"),
|
||||||
|
unmarkedTerminal = rs.getInt("unmarked_terminal"),
|
||||||
|
)
|
||||||
|
} ?: Backlog(0, null, 0)
|
||||||
|
|
||||||
private fun mapProcState(rs: ResultSet) = ProcState(
|
private fun mapProcState(rs: ResultSet) = ProcState(
|
||||||
msgId = rs.getLong("msg_id"),
|
msgId = rs.getLong("msg_id"),
|
||||||
state = ProcStatus.valueOf(rs.getString("state")),
|
state = ProcStatus.valueOf(rs.getString("state")),
|
||||||
@@ -166,8 +258,45 @@ class JdbcProcStateRepository(
|
|||||||
nextAttemptAt = rs.getInstant("next_attempt_at"),
|
nextAttemptAt = rs.getInstant("next_attempt_at"),
|
||||||
errorClass = rs.getString("error_class")?.let(ErrorClass::valueOf),
|
errorClass = rs.getString("error_class")?.let(ErrorClass::valueOf),
|
||||||
lastError = rs.getString("last_error"),
|
lastError = rs.getString("last_error"),
|
||||||
|
receivedAt = rs.getInstant("received_at"),
|
||||||
|
backfillAt = rs.getInstant("backfill_at"),
|
||||||
|
backfillNextAt = rs.getInstant("backfill_next_at"),
|
||||||
|
backfillAttempts = rs.getInt("backfill_attempts"),
|
||||||
|
backfillError = rs.getString("backfill_error"),
|
||||||
updatedAt = rs.getInstant("updated_at") ?: Instant.now(),
|
updatedAt = rs.getInstant("updated_at") ?: Instant.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SELECT_PROC =
|
||||||
|
"SELECT msg_id, state, identity_key, attempts, next_attempt_at, error_class, last_error, " +
|
||||||
|
"received_at, backfill_at, backfill_next_at, backfill_attempts, backfill_error, updated_at FROM proc_state"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 消费水位单行游标(message-lifecycle §5.1);与入队同事务写入。 */
|
||||||
|
@Singleton
|
||||||
|
@Requires(property = "datasources.default.enabled", value = "true")
|
||||||
|
@Requires(missingProperty = "msgx.stubs")
|
||||||
|
class JdbcInboxCursorRepository(
|
||||||
|
private val ds: DataSource,
|
||||||
|
) : InboxCursorRepository {
|
||||||
|
override fun load(): InboxCursorRepository.Cursor =
|
||||||
|
ds.queryOne(
|
||||||
|
"SELECT committed_up_to, hole_since FROM inbox_cursor WHERE cursor_id = 1",
|
||||||
|
{},
|
||||||
|
) { rs -> InboxCursorRepository.Cursor(rs.getLong("committed_up_to"), rs.getInstant("hole_since")) }
|
||||||
|
?: InboxCursorRepository.Cursor()
|
||||||
|
|
||||||
|
override fun save(cursor: InboxCursorRepository.Cursor) {
|
||||||
|
ds.update(
|
||||||
|
"UPDATE inbox_cursor SET committed_up_to = ?, hole_since = ?, updated_at = ? WHERE cursor_id = 1",
|
||||||
|
{ ps ->
|
||||||
|
ps.setLong(1, cursor.committedUpTo)
|
||||||
|
ps.setTimestamp(2, cursor.holeSince?.toSqlTimestamp())
|
||||||
|
ps.setTimestamp(3, Instant.now().toSqlTimestamp())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
@@ -673,64 +802,4 @@ class JdbcReqTrackRepository(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Singleton
|
|
||||||
@Requires(property = "datasources.default.enabled", value = "true")
|
|
||||||
@Requires(missingProperty = "msgx.stubs")
|
|
||||||
class JdbcBackfillTodoRepository(
|
|
||||||
private val ds: DataSource,
|
|
||||||
) : BackfillTodoRepository {
|
|
||||||
override fun record(task: BackfillTodoRepository.BackfillTask, lastError: String?, now: Instant) {
|
|
||||||
ds.update(
|
|
||||||
"""
|
|
||||||
INSERT INTO backfill_todo (msg_id, sndr, type, styp, seqn, attempts, next_attempt_at, last_error, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT (msg_id) DO UPDATE SET last_error = EXCLUDED.last_error, next_attempt_at = EXCLUDED.next_attempt_at, updated_at = EXCLUDED.updated_at
|
|
||||||
""".trimIndent(),
|
|
||||||
{ ps ->
|
|
||||||
ps.setLong(1, task.msgId)
|
|
||||||
ps.setString(2, task.sndr)
|
|
||||||
ps.setString(3, task.type)
|
|
||||||
ps.setString(4, task.styp)
|
|
||||||
ps.setLong(5, task.seqn)
|
|
||||||
ps.setTimestamp(6, now.toSqlTimestamp())
|
|
||||||
ps.setString(7, lastError?.take(512))
|
|
||||||
ps.setTimestamp(8, now.toSqlTimestamp())
|
|
||||||
ps.setTimestamp(9, now.toSqlTimestamp())
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun findDue(now: Instant, limit: Int): List<BackfillTodoRepository.BackfillTask> =
|
|
||||||
ds.query(
|
|
||||||
"SELECT msg_id, sndr, type, styp, seqn, attempts FROM backfill_todo WHERE next_attempt_at <= ? ORDER BY next_attempt_at ASC LIMIT ?",
|
|
||||||
{ ps -> ps.setTimestamp(1, now.toSqlTimestamp()); ps.setInt(2, limit) },
|
|
||||||
) { rs ->
|
|
||||||
BackfillTodoRepository.BackfillTask(
|
|
||||||
msgId = rs.getLong("msg_id"),
|
|
||||||
sndr = rs.getString("sndr"),
|
|
||||||
type = rs.getString("type"),
|
|
||||||
styp = rs.getString("styp"),
|
|
||||||
seqn = rs.getLong("seqn"),
|
|
||||||
attempts = rs.getInt("attempts"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun markFailed(msgId: Long, lastError: String?, nextAttemptAt: Instant, now: Instant) {
|
|
||||||
ds.update(
|
|
||||||
"UPDATE backfill_todo SET attempts = attempts + 1, last_error = ?, next_attempt_at = ?, updated_at = ? WHERE msg_id = ?",
|
|
||||||
{ ps ->
|
|
||||||
ps.setString(1, lastError?.take(512))
|
|
||||||
ps.setTimestamp(2, nextAttemptAt.toSqlTimestamp())
|
|
||||||
ps.setTimestamp(3, now.toSqlTimestamp())
|
|
||||||
ps.setLong(4, msgId)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun delete(msgId: Long) {
|
|
||||||
ds.update("DELETE FROM backfill_todo WHERE msg_id = ?", { ps -> ps.setLong(1, msgId) })
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun count(): Int =
|
|
||||||
ds.queryOne("SELECT count(*) AS n FROM backfill_todo", {}) { rs -> rs.getInt("n") } ?: 0
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import jakarta.inject.Singleton
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* ProcState 侧统一失败迁移(U08/U10):处理/快照路径共用——
|
* ProcState 侧统一失败迁移(U08/U10):处理/快照路径共用——
|
||||||
* attempts+1 后若 exhausted → DEAD(EXHAUSTED)(终态,errorClass 规范化,原因保留在 lastError);
|
* attempts+1 后若 exhausted → DEAD(EXHAUSTED)(终态 + 回填意图,errorClass 规范化,原因保留在 lastError);
|
||||||
* 否则 FAILED + attempts + nextAttemptAt(退避)(可重放)。任何“失败”都不得在无退避下直接终态化。
|
* 否则 FAILED + attempts + nextAttemptAt(退避)(可重放)。任何“失败”都不得在无退避下直接终态化。
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
@@ -16,23 +16,25 @@ class ProcFailure(
|
|||||||
private val procState: ProcStateRepository,
|
private val procState: ProcStateRepository,
|
||||||
val scheduler: FailureScheduler,
|
val scheduler: FailureScheduler,
|
||||||
) {
|
) {
|
||||||
fun fail(head: ProcState, ec: ErrorClass, reason: String) {
|
/** @return 是否已达终态(DEAD 才是终态;FAILED 仍可重放/重试) */
|
||||||
|
fun fail(head: ProcState, ec: ErrorClass, reason: String): Boolean {
|
||||||
val attempts = head.attempts + 1
|
val attempts = head.attempts + 1
|
||||||
if (scheduler.exhausted(attempts)) {
|
if (scheduler.exhausted(attempts)) {
|
||||||
procState.update(
|
procState.markTerminal(
|
||||||
head.msgId, ProcStatus.DEAD,
|
head.msgId, ProcStatus.DEAD,
|
||||||
attempts = attempts,
|
attempts = attempts,
|
||||||
errorClass = ErrorClass.EXHAUSTED,
|
errorClass = ErrorClass.EXHAUSTED,
|
||||||
lastError = "$reason; attempts=$attempts",
|
lastError = "$reason; attempts=$attempts",
|
||||||
)
|
)
|
||||||
} else {
|
return true
|
||||||
procState.update(
|
|
||||||
head.msgId, ProcStatus.FAILED,
|
|
||||||
attempts = attempts,
|
|
||||||
nextAttemptAt = scheduler.nextAttemptAt(attempts),
|
|
||||||
errorClass = ec,
|
|
||||||
lastError = reason,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
procState.update(
|
||||||
|
head.msgId, ProcStatus.FAILED,
|
||||||
|
attempts = attempts,
|
||||||
|
nextAttemptAt = scheduler.nextAttemptAt(attempts),
|
||||||
|
errorClass = ec,
|
||||||
|
lastError = reason,
|
||||||
|
)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ import com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot
|
|||||||
import com.gzzn.omms.msgexchange.domain.flight.FlightState
|
import com.gzzn.omms.msgexchange.domain.flight.FlightState
|
||||||
import com.gzzn.omms.msgexchange.domain.flight.HistoryCandidate
|
import com.gzzn.omms.msgexchange.domain.flight.HistoryCandidate
|
||||||
import com.gzzn.omms.msgexchange.domain.flight.HistoryRules
|
import com.gzzn.omms.msgexchange.domain.flight.HistoryRules
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
|
import com.gzzn.omms.msgexchange.infra.persistence.BackfillDue
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.Backlog
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.InboxCursorRepository
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.MailboxRow
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
|
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
|
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
|
import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
|
||||||
@@ -55,12 +58,12 @@ class StubProcState : ProcStateRepository {
|
|||||||
rows.clear(); bound.clear()
|
rows.clear(); bound.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun insert(msgId: Long, state: ProcStatus) {
|
override fun insertIfAbsent(msgId: Long, receivedAt: Instant?): Boolean {
|
||||||
rows.getOrPut(msgId) { ProcState(msgId, state) }
|
if (rows.containsKey(msgId)) return false
|
||||||
|
rows[msgId] = ProcState(msgId, ProcStatus.PENDING, receivedAt = receivedAt)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun exists(msgId: Long): Boolean = rows.containsKey(msgId)
|
|
||||||
|
|
||||||
override fun find(msgId: Long): ProcState? = rows[msgId]
|
override fun find(msgId: Long): ProcState? = rows[msgId]
|
||||||
|
|
||||||
override fun findSuccessTerminal(msgId: Long): Boolean = rows[msgId]?.state == ProcStatus.SUCCEEDED
|
override fun findSuccessTerminal(msgId: Long): Boolean = rows[msgId]?.state == ProcStatus.SUCCEEDED
|
||||||
@@ -97,6 +100,67 @@ class StubProcState : ProcStateRepository {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 终态 + 回填意图同一动作(模拟单条 UPDATE 的原子性)。 */
|
||||||
|
override fun markTerminal(
|
||||||
|
msgId: Long,
|
||||||
|
state: ProcStatus,
|
||||||
|
errorClass: ErrorClass?,
|
||||||
|
lastError: String?,
|
||||||
|
attempts: Int?,
|
||||||
|
now: Instant,
|
||||||
|
) {
|
||||||
|
val old = rows[msgId] ?: ProcState(msgId, state)
|
||||||
|
rows[msgId] = old.copy(
|
||||||
|
state = state,
|
||||||
|
errorClass = errorClass,
|
||||||
|
lastError = lastError,
|
||||||
|
attempts = attempts ?: old.attempts,
|
||||||
|
nextAttemptAt = null,
|
||||||
|
backfillAt = null,
|
||||||
|
backfillNextAt = now,
|
||||||
|
backfillAttempts = 0,
|
||||||
|
backfillError = null,
|
||||||
|
updatedAt = now,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun markBackfilled(msgId: Long, now: Instant) {
|
||||||
|
rows[msgId]?.let {
|
||||||
|
rows[msgId] = it.copy(backfillAt = now, backfillNextAt = null, backfillError = null, updatedAt = now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun recordBackfillFailure(msgId: Long, error: String?, attempts: Int, nextAttemptAt: Instant, now: Instant) {
|
||||||
|
rows[msgId]?.let {
|
||||||
|
rows[msgId] = it.copy(
|
||||||
|
backfillAttempts = attempts,
|
||||||
|
backfillNextAt = nextAttemptAt,
|
||||||
|
backfillError = error,
|
||||||
|
updatedAt = now,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findBackfillDue(now: Instant, overdueBefore: Instant, limit: Int): List<BackfillDue> =
|
||||||
|
rows.values
|
||||||
|
.filter { it.state.isTerminal() && it.backfillAt == null }
|
||||||
|
.filter {
|
||||||
|
it.backfillNextAt == null || it.backfillNextAt <= now ||
|
||||||
|
(it.receivedAt != null && it.receivedAt < overdueBefore)
|
||||||
|
}
|
||||||
|
.sortedBy { it.msgId }
|
||||||
|
.take(limit)
|
||||||
|
.map { BackfillDue(it.msgId, it.backfillAttempts) }
|
||||||
|
|
||||||
|
override fun backlog(): Backlog {
|
||||||
|
val unfinished = rows.values.filter { it.state == ProcStatus.PENDING || it.state == ProcStatus.FAILED }
|
||||||
|
return Backlog(
|
||||||
|
unfinished = unfinished.size,
|
||||||
|
oldestReceivedAt = unfinished.mapNotNull { it.receivedAt }.minOrNull(),
|
||||||
|
unmarkedTerminal = rows.values.count { it.state.isTerminal() && it.backfillAt == null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int {
|
override fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int {
|
||||||
var n = 0
|
var n = 0
|
||||||
rows.forEach { (id, s) ->
|
rows.forEach { (id, s) ->
|
||||||
@@ -300,60 +364,69 @@ class StubReqTrack : ReqTrackRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Singleton
|
|
||||||
@Requires(property = "msgx.stubs", value = "true")
|
|
||||||
class StubBackfillTodo : BackfillTodoRepository {
|
|
||||||
val tasks = linkedMapOf<Long, BackfillTodoRepository.BackfillTask>()
|
|
||||||
private val errors = linkedMapOf<Long, String?>()
|
|
||||||
private val due = linkedMapOf<Long, Instant>()
|
|
||||||
|
|
||||||
fun clear() { tasks.clear(); errors.clear(); due.clear() }
|
|
||||||
|
|
||||||
fun lastErrorOf(msgId: Long): String? = errors[msgId]
|
|
||||||
|
|
||||||
override fun record(task: BackfillTodoRepository.BackfillTask, lastError: String?, now: Instant) {
|
|
||||||
errors[task.msgId] = lastError
|
|
||||||
due.putIfAbsent(task.msgId, now)
|
|
||||||
tasks[task.msgId] = task.copy(attempts = tasks[task.msgId]?.attempts ?: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun findDue(now: Instant, limit: Int): List<BackfillTodoRepository.BackfillTask> =
|
|
||||||
tasks.keys.filter { (due[it] ?: Instant.EPOCH) <= now }.take(limit).mapNotNull { tasks[it] }
|
|
||||||
|
|
||||||
override fun markFailed(msgId: Long, lastError: String?, nextAttemptAt: Instant, now: Instant) {
|
|
||||||
errors[msgId] = lastError
|
|
||||||
due[msgId] = nextAttemptAt
|
|
||||||
tasks[msgId]?.let { tasks[msgId] = it.copy(attempts = it.attempts + 1) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun delete(msgId: Long) {
|
|
||||||
tasks.remove(msgId); errors.remove(msgId); due.remove(msgId)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun count(): Int = tasks.size
|
|
||||||
}
|
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
@Requires(property = "msgx.stubs", value = "true")
|
@Requires(property = "msgx.stubs", value = "true")
|
||||||
class StubInbox : CminmsgInboxRepository {
|
class StubInbox : CminmsgInboxRepository {
|
||||||
val raws = linkedMapOf<Long, String>()
|
val raws = linkedMapOf<Long, String>()
|
||||||
|
private val received = linkedMapOf<Long, Instant>()
|
||||||
|
private val marks = linkedMapOf<Long, String>()
|
||||||
private val ids = AtomicLong(0)
|
private val ids = AtomicLong(0)
|
||||||
|
|
||||||
fun clear() = raws.clear()
|
fun clear() {
|
||||||
|
raws.clear(); received.clear(); marks.clear(); ids.set(0) // ID 自 1 重新分配,用例间不串扰
|
||||||
|
}
|
||||||
|
|
||||||
override fun insertRaw(rawXml: String): Long {
|
override fun insertRaw(rawXml: String): Long {
|
||||||
val id = ids.incrementAndGet()
|
val id = ids.incrementAndGet()
|
||||||
raws[id] = rawXml
|
raws[id] = rawXml
|
||||||
|
received[id] = Instant.now()
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun rawOf(msgId: Long): String? = raws[msgId]
|
override fun rawOf(msgId: Long): String? = raws[msgId]
|
||||||
|
|
||||||
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> =
|
override fun readRange(fromExclusive: Long, limit: Int): List<MailboxRow> =
|
||||||
raws.keys.filter { it > afterId }.sorted().take(limit)
|
raws.keys.filter { it > fromExclusive }.sorted().take(limit).map { MailboxRow(it, received[it]) }
|
||||||
|
|
||||||
override fun backfillOnSuccess(msgId: Long, sndr: String, type: String, styp: String, seqn: Long) = Unit
|
override fun maxId(): Long? = raws.keys.maxOrNull()
|
||||||
|
|
||||||
|
/** §11 单调:只把空标写为已处理;已有值不回撤、不覆盖。 */
|
||||||
|
override fun markProcessedIfUnmarked(msgId: Long, value: String): Boolean {
|
||||||
|
if (!raws.containsKey(msgId) || marks.containsKey(msgId)) return false
|
||||||
|
marks[msgId] = value
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markOf(msgId: Long): String? = marks[msgId]
|
||||||
|
|
||||||
|
fun isMarked(msgId: Long): Boolean = marks.containsKey(msgId)
|
||||||
|
|
||||||
/** 测试辅助:模拟上游外部写入共享信箱(不经本系统)。 */
|
/** 测试辅助:模拟上游外部写入共享信箱(不经本系统)。 */
|
||||||
fun simulateExternalWrite(rawXml: String): Long = insertRaw(rawXml)
|
fun simulateExternalWrite(rawXml: String): Long = insertRaw(rawXml)
|
||||||
|
|
||||||
|
/** 测试辅助:模拟库方清除(原文不可读),用于 §9 原文缺失与空洞场景。 */
|
||||||
|
fun removeRow(msgId: Long) {
|
||||||
|
raws.remove(msgId); received.remove(msgId); marks.remove(msgId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 消费水位游标(stub)。 */
|
||||||
|
@Singleton
|
||||||
|
@Requires(property = "msgx.stubs", value = "true")
|
||||||
|
class StubInboxCursor : InboxCursorRepository {
|
||||||
|
var cursor: InboxCursorRepository.Cursor = InboxCursorRepository.Cursor()
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
cursor = InboxCursorRepository.Cursor()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun load(): InboxCursorRepository.Cursor = cursor
|
||||||
|
|
||||||
|
override fun save(cursor: InboxCursorRepository.Cursor) {
|
||||||
|
this.cursor = cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 终态判定(§2 状态总纲)。 */
|
||||||
|
private fun ProcStatus.isTerminal(): Boolean =
|
||||||
|
this == ProcStatus.SUCCEEDED || this == ProcStatus.SKIPPED || this == ProcStatus.DEAD
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
package com.gzzn.omms.msgexchange.ingress
|
|
||||||
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
|
|
||||||
import jakarta.inject.Singleton
|
|
||||||
|
|
||||||
/** 流程 1 入队:共享库 CMINMSGS_ID → 自有 PG PROC_STATE(PENDING),幂等跳过已存在行。 */
|
|
||||||
@Singleton
|
|
||||||
class InboxEnqueue(private val procState: ProcStateRepository) {
|
|
||||||
|
|
||||||
/** @return true 若新建 PENDING 行;false 若已存在(轮询重扫/compensate 幂等)。 */
|
|
||||||
fun enqueue(msgId: Long): Boolean {
|
|
||||||
if (procState.exists(msgId)) return false
|
|
||||||
procState.insert(msgId)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,18 +2,34 @@ package com.gzzn.omms.msgexchange.ingress
|
|||||||
|
|
||||||
import com.gzzn.omms.msgexchange.config.PipelineProps
|
import com.gzzn.omms.msgexchange.config.PipelineProps
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
||||||
import com.gzzn.omms.msgexchange.jobs.BackfillSweepJob
|
import com.gzzn.omms.msgexchange.infra.persistence.InboxCursorRepository
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.MailboxRow
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
|
||||||
import jakarta.inject.Singleton
|
import jakarta.inject.Singleton
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ACMA-8 流程 1 · 主路径:JDBC 轮询共享 MySQL CMINMSGS(`DATE_PROCESSED IS NULL`),
|
* 收报(docs/message-lifecycle.md §5.1):共享 MySQL 信箱按 **ID 区间**升序有界读取,
|
||||||
* 发现新信后入队自有 PG。与 legacy `MsgExchangeRunner.getNewMsgsAfterId(0L)` 同语义。
|
* 在自有 PG 建 `PROC_STATE(PENDING)`,并把消费水位推进到连续上界;入队与水位推进在同一
|
||||||
|
* PG 事务内提交,中断即重扫补建(§4 第一行)。收报层不解析业务载荷、不写处理标记。
|
||||||
|
*
|
||||||
|
* 三条红线:
|
||||||
|
* - **扫描谓词不含处理标记**:标记只用于回填与库方清除,不参与消息发现;否则已入队而未
|
||||||
|
* 回填的行会永久占据批次,死信累积到批大小时收报整体停摆;
|
||||||
|
* - **水位遇空洞即停**:不越过空洞入队(越过后较小 ID 迟到即 FIFO 越序,architecture §5);
|
||||||
|
* - **空洞老化**:超过最大提交时延(Q2 承诺)的空洞判定为永久并放行,否则水位会永久停摆于
|
||||||
|
* 一次自增回滚留下的空位。
|
||||||
|
*
|
||||||
|
* 空转代价为每轮一次区间 SELECT。
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class InboxPoller(
|
class InboxPoller(
|
||||||
private val inbox: CminmsgInboxRepository,
|
private val mailbox: CminmsgInboxRepository,
|
||||||
private val enqueue: InboxEnqueue,
|
private val procState: ProcStateRepository,
|
||||||
private val backfillSweep: BackfillSweepJob,
|
private val cursor: InboxCursorRepository,
|
||||||
|
private val txManager: PipelineTransactionManager,
|
||||||
private val props: PipelineProps,
|
private val props: PipelineProps,
|
||||||
) {
|
) {
|
||||||
private val log = org.slf4j.LoggerFactory.getLogger(InboxPoller::class.java)
|
private val log = org.slf4j.LoggerFactory.getLogger(InboxPoller::class.java)
|
||||||
@@ -21,31 +37,40 @@ class InboxPoller(
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var running = false
|
private var running = false
|
||||||
|
|
||||||
/** legacy 现役:afterId=0,每轮扫全部未处理行;PROC_STATE 判重防重复入队。 */
|
/** @return 本轮新建的入队条数 */
|
||||||
fun pollOnce(): Int {
|
fun pollOnce(now: Instant = Instant.now()): Int {
|
||||||
val batch = props.pipeline.claimBatch.coerceAtLeast(1)
|
val batch = props.pipeline.claimBatch.coerceAtLeast(1)
|
||||||
val ids = inbox.pollUnprocessed(afterId = 0L, limit = batch)
|
val watermark = cursor.load()
|
||||||
var enqueued = 0
|
val rows = mailbox.readRange(watermark.committedUpTo, batch)
|
||||||
for (id in ids) {
|
if (rows.isEmpty()) return 0
|
||||||
if (enqueue.enqueue(id)) {
|
|
||||||
enqueued++
|
|
||||||
log.info("polled msgId={}", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sweepBackfillTodos()
|
|
||||||
return enqueued
|
|
||||||
}
|
|
||||||
|
|
||||||
/** v2 §5:每轮心跳顺带对账回填补偿待办(重启即恢复;空表只花一次索引 SELECT)。 */
|
// 连续上界;读取区间内出现空洞时,只推进到连续部分,空洞之后的行暂不入队(防较小 ID 迟到被越过)
|
||||||
private fun sweepBackfillTodos() {
|
val contiguous = contiguousUpTo(watermark.committedUpTo, rows) ?: watermark.committedUpTo
|
||||||
try {
|
var committedTo = contiguous
|
||||||
val outcome = backfillSweep.sweep()
|
var holeSince: Instant? = null
|
||||||
if (outcome.inspected > 0) {
|
if (rows.last().msgId > contiguous) {
|
||||||
log.info("backfill sweep inspected={} succeeded={} failed={}", outcome.inspected, outcome.succeeded, outcome.failed)
|
val since = watermark.holeSince ?: now
|
||||||
|
if (Duration.between(since, now) < props.pipeline.maxCommitDelay) {
|
||||||
|
holeSince = since
|
||||||
|
} else {
|
||||||
|
// 空洞老化:超过最大提交时延仍缺席即判永久(Q2),放行水位,否则永久停摆
|
||||||
|
committedTo = rows.first { it.msgId > contiguous }.msgId - 1
|
||||||
|
log.warn("hole after W={} aged out, watermark advanced to {}", contiguous, committedTo)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
log.error("backfill sweep tick failed", e)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val enqueued = txManager.inTransaction {
|
||||||
|
var n = 0
|
||||||
|
rows.takeWhile { it.msgId <= committedTo }.forEach { row ->
|
||||||
|
if (procState.insertIfAbsent(row.msgId, row.receivedAt)) n++
|
||||||
|
}
|
||||||
|
cursor.save(InboxCursorRepository.Cursor(committedTo, holeSince))
|
||||||
|
n
|
||||||
|
}
|
||||||
|
if (enqueued > 0) {
|
||||||
|
log.info("polled {} new messages, W {} -> {}", enqueued, watermark.committedUpTo, committedTo)
|
||||||
|
}
|
||||||
|
return enqueued
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loop() {
|
fun loop() {
|
||||||
@@ -70,7 +95,19 @@ class InboxPoller(
|
|||||||
running = false
|
running = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sleepQuietly(d: java.time.Duration) {
|
/** 连续上界:从 W+1 起 ID 逐 1 相邻的最后一个;首个空位即停(rows 为升序且覆盖该区间)。 */
|
||||||
|
private fun contiguousUpTo(from: Long, rows: List<MailboxRow>): Long? {
|
||||||
|
var expected = from + 1
|
||||||
|
var last: Long? = null
|
||||||
|
for (row in rows) {
|
||||||
|
if (row.msgId != expected) break
|
||||||
|
last = row.msgId
|
||||||
|
expected++
|
||||||
|
}
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sleepQuietly(d: Duration) {
|
||||||
try {
|
try {
|
||||||
Thread.sleep(d.toMillis().coerceAtLeast(1))
|
Thread.sleep(d.toMillis().coerceAtLeast(1))
|
||||||
} catch (_: InterruptedException) {
|
} catch (_: InterruptedException) {
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
package com.gzzn.omms.msgexchange.ingress
|
package com.gzzn.omms.msgexchange.ingress
|
||||||
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
|
||||||
import jakarta.inject.Singleton
|
import jakarta.inject.Singleton
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
/** ACMA-8 流程 1 · compat 写路径:HTTP 落信 + PG 入队(非生产主拓扑;主路径=InboxPoller JDBC 轮询)。 */
|
/**
|
||||||
|
* ACMA-8 流程 1 · compat 写路径:HTTP 落信 + PG 入队(非生产主拓扑;主路径=InboxPoller 轮询)。
|
||||||
|
* 两步不在同一事务:信箱成功而 PG 失败时原文不丢失,由轮询按 ID 区间补建(design.md §3.1)。
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class InboxService(
|
class InboxService(
|
||||||
private val inbox: CminmsgInboxRepository,
|
private val inbox: CminmsgInboxRepository,
|
||||||
private val enqueue: InboxEnqueue,
|
private val procState: ProcStateRepository,
|
||||||
) {
|
) {
|
||||||
private val log = org.slf4j.LoggerFactory.getLogger(InboxService::class.java)
|
private val log = org.slf4j.LoggerFactory.getLogger(InboxService::class.java)
|
||||||
|
|
||||||
data class Receipt(val msgId: Long, val receivedAt: Instant)
|
data class Receipt(val msgId: Long, val receivedAt: Instant)
|
||||||
|
|
||||||
fun accept(rawXml: String): Receipt {
|
fun accept(rawXml: String, now: Instant = Instant.now()): Receipt {
|
||||||
val id = inbox.insertRaw(rawXml)
|
val id = inbox.insertRaw(rawXml)
|
||||||
enqueue.enqueue(id)
|
procState.insertIfAbsent(id, now)
|
||||||
log.info("compat-accepted msgId={}", id)
|
log.info("compat-accepted msgId={}", id)
|
||||||
return Receipt(id, Instant.now())
|
return Receipt(id, now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
package com.gzzn.omms.msgexchange.jobs
|
|
||||||
|
|
||||||
import com.gzzn.omms.msgexchange.config.PipelineProps
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
|
||||||
import jakarta.inject.Singleton
|
|
||||||
import java.time.Duration
|
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* v2 §5(ACM2-29 P2-3):共享信箱回填补偿扫描。
|
|
||||||
*
|
|
||||||
* 业务事务提交(SUCCEEDED)后 backfillOnSuccess 失败 → 落 backfill_todo;
|
|
||||||
* 本任务到期重试:成功即删除待办,失败按指数退避推后(30s 起步,封顶 15 分钟)。
|
|
||||||
* 回填失败绝不重放航班业务变更——待办里只有信箱回填所需的路由元数据。
|
|
||||||
*
|
|
||||||
* 触发点:InboxPoller 心跳(重启即对账)+ JobExecutor("BACKFILL_SWEEP")(FIFO/手工)。
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class BackfillSweepJob(
|
|
||||||
private val todo: BackfillTodoRepository,
|
|
||||||
private val inbox: CminmsgInboxRepository,
|
|
||||||
@Suppress("unused") private val props: PipelineProps,
|
|
||||||
) {
|
|
||||||
data class SweepOutcome(val inspected: Int, val succeeded: Int, val failed: Int)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val INITIAL_BACKOFF: Duration = Duration.ofSeconds(30)
|
|
||||||
private val MAX_BACKOFF: Duration = Duration.ofMinutes(15)
|
|
||||||
|
|
||||||
fun backoffDelayFor(attempts: Int): Duration {
|
|
||||||
val shift = (attempts - 1).coerceIn(0, 20)
|
|
||||||
return INITIAL_BACKOFF.multipliedBy(1L shl shift).coerceAtMost(MAX_BACKOFF)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sweep(now: Instant = Instant.now()): SweepOutcome {
|
|
||||||
val due = todo.findDue(now)
|
|
||||||
var succeeded = 0
|
|
||||||
var failed = 0
|
|
||||||
for (task in due) {
|
|
||||||
try {
|
|
||||||
inbox.backfillOnSuccess(task.msgId, task.sndr, task.type, task.styp, task.seqn)
|
|
||||||
todo.delete(task.msgId)
|
|
||||||
succeeded++
|
|
||||||
} catch (e: Exception) {
|
|
||||||
todo.markFailed(task.msgId, e.message, now.plus(backoffDelayFor(task.attempts + 1)), now)
|
|
||||||
failed++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return SweepOutcome(due.size, succeeded, failed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ package com.gzzn.omms.msgexchange.jobs
|
|||||||
|
|
||||||
import com.gzzn.omms.msgexchange.config.HistoryProps
|
import com.gzzn.omms.msgexchange.config.HistoryProps
|
||||||
import com.gzzn.omms.msgexchange.config.PipelineProps
|
import com.gzzn.omms.msgexchange.config.PipelineProps
|
||||||
|
import com.gzzn.omms.msgexchange.processing.BackfillService
|
||||||
import jakarta.inject.Singleton
|
import jakarta.inject.Singleton
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -11,11 +12,12 @@ import java.time.ZoneId
|
|||||||
/**
|
/**
|
||||||
* 维护作业调度(docs/design.md §6.1/§6.2):单 daemon 线程,独立于主泵——
|
* 维护作业调度(docs/design.md §6.1/§6.2):单 daemon 线程,独立于主泵——
|
||||||
* 作业不参与消息 FIFO,也不使到期消息饥饿(PUMP_JOB 队列机制已随审计口径移除)。
|
* 作业不参与消息 FIFO,也不使到期消息饥饿(PUMP_JOB 队列机制已随审计口径移除)。
|
||||||
* 触发:回填补偿扫描 30s 固定间隔;历史归档/留痕清理每日机场时区 03:30 后首个 tick。
|
* 触发:回填补写扫描 30s 固定间隔;历史归档/留痕清理每日机场时区 03:30 后首个 tick。
|
||||||
|
* 回填意图由处理器在终态事务内登记(message-lifecycle §4),本线程只负责到期重试。
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class JobRunner(
|
class JobRunner(
|
||||||
private val backfillSweep: BackfillSweepJob,
|
private val backfill: BackfillService,
|
||||||
private val historySweep: HistorySweepJob,
|
private val historySweep: HistorySweepJob,
|
||||||
@Suppress("unused") private val pipelineProps: PipelineProps,
|
@Suppress("unused") private val pipelineProps: PipelineProps,
|
||||||
@Suppress("unused") private val historyProps: HistoryProps,
|
@Suppress("unused") private val historyProps: HistoryProps,
|
||||||
@@ -47,7 +49,7 @@ class JobRunner(
|
|||||||
internal fun loop() {
|
internal fun loop() {
|
||||||
while (running) {
|
while (running) {
|
||||||
try {
|
try {
|
||||||
backfillSweep.sweep()
|
backfill.sweep()
|
||||||
maybeHistorySweep()
|
maybeHistorySweep()
|
||||||
} catch (e: InterruptedException) {
|
} catch (e: InterruptedException) {
|
||||||
Thread.currentThread().interrupt()
|
Thread.currentThread().interrupt()
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.gzzn.omms.msgexchange.processing
|
||||||
|
|
||||||
|
import com.gzzn.omms.msgexchange.config.MailboxProps
|
||||||
|
import com.gzzn.omms.msgexchange.config.PipelineProps
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
|
||||||
|
import jakarta.inject.Singleton
|
||||||
|
import java.time.Clock
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 信箱处理标记回填(docs/message-lifecycle.md §3/§4/§5.2)。
|
||||||
|
*
|
||||||
|
* 回填意图(`BACKFILL_NEXT_AT`)由处理器在终态事务内登记,与业务写入同提交同回滚,
|
||||||
|
* 因此不存在"业务已提交、待办未记"的窗口;本服务只做两件事:
|
||||||
|
* 1. [attempt]:终态提交后立即尝试一次(低延迟,失败静默留给扫描);
|
||||||
|
* 2. [sweep]:到期或已达超期期限 R 的记录批量补写(§5.2),指数退避 30s 起步、封顶 15 分钟。
|
||||||
|
*
|
||||||
|
* 回填失败绝不重放业务变更,也绝不回改终态(§11);写入侧只把空标写为已处理,
|
||||||
|
* 重复执行无副作用。
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class BackfillService(
|
||||||
|
private val procState: ProcStateRepository,
|
||||||
|
private val mailbox: CminmsgInboxRepository,
|
||||||
|
private val mailboxProps: MailboxProps,
|
||||||
|
private val props: PipelineProps,
|
||||||
|
private val clock: Clock,
|
||||||
|
) {
|
||||||
|
private val log = org.slf4j.LoggerFactory.getLogger(BackfillService::class.java)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val INITIAL_BACKOFF: Duration = Duration.ofSeconds(30)
|
||||||
|
private val MAX_BACKOFF: Duration = Duration.ofMinutes(15)
|
||||||
|
|
||||||
|
fun backoffDelayFor(attempts: Int): Duration {
|
||||||
|
val shift = (attempts - 1).coerceIn(0, 20)
|
||||||
|
return INITIAL_BACKOFF.multipliedBy(1L shl shift).coerceAtMost(MAX_BACKOFF)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单条最佳努力回填;失败只登记退避(异常不外抛,不阻塞提交后的处理路径)。 */
|
||||||
|
fun attempt(msgId: Long, now: Instant = clock.instant()) {
|
||||||
|
record(msgId, attempts = 0, now = now)?.let {
|
||||||
|
log.warn("backfill failed msgId={} error={} (sweep will retry)", msgId, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量补写(JobRunner 每 30s 触发;重启即继续,不依赖内存状态)。
|
||||||
|
* @return 本批检查条数
|
||||||
|
*/
|
||||||
|
fun sweep(now: Instant = clock.instant()): Int {
|
||||||
|
val due = procState.findBackfillDue(now, now.minus(props.pipeline.overdueBackfill), props.pipeline.backfillBatch)
|
||||||
|
due.forEach { record(it.msgId, it.attempts, now) }
|
||||||
|
return due.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 失败原因;null = 已确认标记(含"已被其他路径标记"的幂等成功) */
|
||||||
|
private fun record(msgId: Long, attempts: Int, now: Instant): String? =
|
||||||
|
try {
|
||||||
|
// 影响 0 行 = 已有标记;按幂等成功处理(§11 标记单调:不回撤、不覆盖)
|
||||||
|
mailbox.markProcessedIfUnmarked(msgId, mailboxProps.processedValue)
|
||||||
|
procState.markBackfilled(msgId, now)
|
||||||
|
null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val reason = e.message ?: e.javaClass.simpleName
|
||||||
|
runCatching {
|
||||||
|
procState.recordBackfillFailure(msgId, reason, attempts + 1, now.plus(backoffDelayFor(attempts + 1)), now)
|
||||||
|
}.onFailure { log.error("record backfill failure failed msgId={}", msgId, it) }
|
||||||
|
reason
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,17 +8,18 @@ import com.gzzn.omms.msgexchange.domain.EventType
|
|||||||
import com.gzzn.omms.msgexchange.domain.MsgEvent
|
import com.gzzn.omms.msgexchange.domain.MsgEvent
|
||||||
import com.gzzn.omms.msgexchange.domain.OperationDayCalculator
|
import com.gzzn.omms.msgexchange.domain.OperationDayCalculator
|
||||||
import com.gzzn.omms.msgexchange.domain.ProcState
|
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.Targets
|
||||||
import com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot
|
import com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot
|
||||||
import com.gzzn.omms.msgexchange.domain.flight.FlightState
|
import com.gzzn.omms.msgexchange.domain.flight.FlightState
|
||||||
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
|
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
|
||||||
import com.gzzn.omms.msgexchange.domain.flight.MergeChange
|
import com.gzzn.omms.msgexchange.domain.flight.MergeChange
|
||||||
import com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord
|
import com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
|
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
|
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository
|
import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
|
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
|
||||||
import jakarta.inject.Singleton
|
import jakarta.inject.Singleton
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
@@ -34,19 +35,23 @@ class FlopProcessor(
|
|||||||
private val lock: PipelineLockRepository,
|
private val lock: PipelineLockRepository,
|
||||||
private val flightState: FlightStateRepository,
|
private val flightState: FlightStateRepository,
|
||||||
private val msgEvents: MsgEventRepository,
|
private val msgEvents: MsgEventRepository,
|
||||||
private val backfillTodo: BackfillTodoRepository?,
|
private val procState: ProcStateRepository,
|
||||||
private val mapper: ObjectMapper,
|
private val mapper: ObjectMapper,
|
||||||
) {
|
) {
|
||||||
fun apply(head: ProcState, msg: DecodedMessage, payload: FlopPayload): ApplyResult = txManager.inTransaction {
|
fun apply(head: ProcState, msg: DecodedMessage, payload: FlopPayload): ApplyResult = txManager.inTransaction {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
val current = flightState.loadFullSnapshot(payload.flid)
|
val current = flightState.loadFullSnapshot(payload.flid)
|
||||||
?: return@inTransaction idempotentAbsent(head, msg) // 迟到/未知航班:幂等成功,不创建
|
if (current == null) {
|
||||||
|
// 迟到/未知航班:幂等成功,不创建(创建入口只有 SCHD/ADFT);终态同事务落库
|
||||||
|
procState.markTerminal(head.msgId, ProcStatus.SUCCEEDED)
|
||||||
|
return@inTransaction ApplyResult.Succeeded
|
||||||
|
}
|
||||||
|
|
||||||
val change = MergeChange(flid = payload.flid, scalars = payload.scalars, collections = payload.collections)
|
val change = MergeChange(flid = payload.flid, scalars = payload.scalars, collections = payload.collections)
|
||||||
val next = FlightStateEngine.mergedState(current, change)
|
val next = FlightStateEngine.mergedState(current, change)
|
||||||
flightState.persistFullState(next, msgId = head.msgId, now = Instant.now())
|
flightState.persistFullState(next, msgId = head.msgId, now = Instant.now())
|
||||||
msgEvents.insertAll(eventsFor(next, mapper))
|
msgEvents.insertAll(eventsFor(next, mapper))
|
||||||
preRegisterBackfill(head, msg, backfillTodo)
|
procState.markTerminal(head.msgId, ProcStatus.SUCCEEDED)
|
||||||
ApplyResult.Succeeded
|
ApplyResult.Succeeded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,7 +66,7 @@ class FdelProcessor(
|
|||||||
private val lock: PipelineLockRepository,
|
private val lock: PipelineLockRepository,
|
||||||
private val flightState: FlightStateRepository,
|
private val flightState: FlightStateRepository,
|
||||||
private val msgEvents: MsgEventRepository,
|
private val msgEvents: MsgEventRepository,
|
||||||
private val backfillTodo: BackfillTodoRepository?,
|
private val procState: ProcStateRepository,
|
||||||
private val mapper: ObjectMapper,
|
private val mapper: ObjectMapper,
|
||||||
) {
|
) {
|
||||||
fun apply(head: ProcState, msg: DecodedMessage, payload: FlopPayload): ApplyResult = txManager.inTransaction {
|
fun apply(head: ProcState, msg: DecodedMessage, payload: FlopPayload): ApplyResult = txManager.inTransaction {
|
||||||
@@ -95,9 +100,9 @@ class FdelProcessor(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
preRegisterBackfill(head, msg, backfillTodo)
|
|
||||||
}
|
}
|
||||||
ApplyResult.Succeeded // 未命中 = 迟到/重复,幂等成功(§3.3)
|
procState.markTerminal(head.msgId, ProcStatus.SUCCEEDED) // 未命中 = 迟到/重复,幂等成功(§3.3)
|
||||||
|
ApplyResult.Succeeded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +117,7 @@ class AdftProcessor(
|
|||||||
private val lock: PipelineLockRepository,
|
private val lock: PipelineLockRepository,
|
||||||
private val flightState: FlightStateRepository,
|
private val flightState: FlightStateRepository,
|
||||||
private val msgEvents: MsgEventRepository,
|
private val msgEvents: MsgEventRepository,
|
||||||
private val backfillTodo: BackfillTodoRepository?,
|
private val procState: ProcStateRepository,
|
||||||
operationDayProps: OperationDayProps,
|
operationDayProps: OperationDayProps,
|
||||||
private val mapper: ObjectMapper,
|
private val mapper: ObjectMapper,
|
||||||
) {
|
) {
|
||||||
@@ -135,7 +140,7 @@ class AdftProcessor(
|
|||||||
msgEvents.insertAll(eventsFor(next, mapper))
|
msgEvents.insertAll(eventsFor(next, mapper))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
preRegisterBackfill(head, msg, backfillTodo)
|
procState.markTerminal(head.msgId, ProcStatus.SUCCEEDED)
|
||||||
return@inTransaction ApplyResult.Succeeded
|
return@inTransaction ApplyResult.Succeeded
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +166,7 @@ class AdftProcessor(
|
|||||||
"operation-day guard violated flid=${record.flid}"
|
"operation-day guard violated flid=${record.flid}"
|
||||||
}
|
}
|
||||||
msgEvents.insertAll(eventsFor(next, mapper))
|
msgEvents.insertAll(eventsFor(next, mapper))
|
||||||
preRegisterBackfill(head, msg, backfillTodo)
|
procState.markTerminal(head.msgId, ProcStatus.SUCCEEDED)
|
||||||
ApplyResult.Succeeded
|
ApplyResult.Succeeded
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,9 +182,6 @@ class AdftProcessor(
|
|||||||
// 共享小工具(处理器层私有约定)
|
// 共享小工具(处理器层私有约定)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
||||||
/** 航班不存在/迟到:幂等成功(§3.2/§3.3;不阻塞队头,不创建实例——创建入口只有 SCHD/ADFT)。 */
|
|
||||||
private fun idempotentAbsent(head: ProcState, msg: DecodedMessage): ApplyResult = ApplyResult.Succeeded
|
|
||||||
|
|
||||||
/** KAFKA_SCHD 整态 + KAFKA_MSG 变化通知(flight-state.md §3.1/§5)。 */
|
/** KAFKA_SCHD 整态 + KAFKA_MSG 变化通知(flight-state.md §3.1/§5)。 */
|
||||||
internal fun eventsFor(next: FlightSnapshot, mapper: ObjectMapper): List<MsgEvent> {
|
internal fun eventsFor(next: FlightSnapshot, mapper: ObjectMapper): List<MsgEvent> {
|
||||||
val payload = linkedMapOf<String, Any>(
|
val payload = linkedMapOf<String, Any>(
|
||||||
@@ -203,14 +205,3 @@ internal fun eventsFor(next: FlightSnapshot, mapper: ObjectMapper): List<MsgEven
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 回填待办与业务终态同事务预登记(design.md §3.3/§6.1;提交后回填并删待办)。 */
|
|
||||||
internal fun preRegisterBackfill(head: ProcState, msg: DecodedMessage, backfillTodo: BackfillTodoRepository?) {
|
|
||||||
backfillTodo?.record(
|
|
||||||
BackfillTodoRepository.BackfillTask(
|
|
||||||
msgId = head.msgId, sndr = msg.meta.sndr, type = msg.meta.type,
|
|
||||||
styp = msg.meta.styp, seqn = msg.meta.seqn,
|
|
||||||
),
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,25 +10,28 @@ import com.gzzn.omms.msgexchange.domain.MsgKind
|
|||||||
import com.gzzn.omms.msgexchange.domain.ProcState
|
import com.gzzn.omms.msgexchange.domain.ProcState
|
||||||
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
||||||
import com.gzzn.omms.msgexchange.infra.log.TraceLog
|
import com.gzzn.omms.msgexchange.infra.log.TraceLog
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
|
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
|
||||||
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
|
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
|
||||||
import jakarta.inject.Singleton
|
import jakarta.inject.Singleton
|
||||||
|
import java.time.Clock
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理主泵(docs/flight-state.md §1 严格有序 + §4 处理事务与失败规则):
|
* 处理主泵(docs/flight-state.md §1 严格有序 + §4 处理事务与失败规则):
|
||||||
* 单活动主泵严格 FIFO + HOL 阻塞 + 队头滞留转 DEAD;航班状态、事件、处理终态
|
* 单活动主泵严格 FIFO + HOL 阻塞 + 队头滞留转 DEAD。
|
||||||
* 与回填待办在处理器事务内原子提交。
|
*
|
||||||
|
* 终态与业务写入在处理器事务内原子提交;本泵只负责调度、边界化失败迁移与
|
||||||
|
* 提交后的最佳努力回填(message-lifecycle §3/§4)。
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class Pump(
|
class Pump(
|
||||||
private val procState: ProcStateRepository,
|
private val procState: ProcStateRepository,
|
||||||
private val inbox: CminmsgInboxRepository,
|
|
||||||
private val processor: MessageProcessor,
|
private val processor: MessageProcessor,
|
||||||
|
private val backfill: BackfillService,
|
||||||
private val props: PipelineProps,
|
private val props: PipelineProps,
|
||||||
|
private val clock: Clock,
|
||||||
) {
|
) {
|
||||||
private val log = org.slf4j.LoggerFactory.getLogger(Pump::class.java)
|
private val log = org.slf4j.LoggerFactory.getLogger(Pump::class.java)
|
||||||
|
|
||||||
@@ -58,15 +61,18 @@ class Pump(
|
|||||||
val head = procState.headUnfinished()
|
val head = procState.headUnfinished()
|
||||||
when {
|
when {
|
||||||
head == null -> sleepQuietly(props.pipeline.pollInterval)
|
head == null -> sleepQuietly(props.pipeline.pollInterval)
|
||||||
head.state == ProcStatus.FAILED && (head.nextAttemptAt ?: Instant.EPOCH) > Instant.now() ->
|
head.state == ProcStatus.FAILED && (head.nextAttemptAt ?: Instant.EPOCH) > clock.instant() ->
|
||||||
if (poisoned(head)) {
|
if (poisoned(head)) {
|
||||||
log.error("poison -> DEAD msgId={} attempts={} lastError={}", head.msgId, head.attempts, head.lastError)
|
log.error("poison -> DEAD msgId={} attempts={} lastError={}", head.msgId, head.attempts, head.lastError)
|
||||||
procState.update(
|
procState.markTerminal(
|
||||||
head.msgId, ProcStatus.DEAD,
|
head.msgId, ProcStatus.DEAD,
|
||||||
errorClass = ErrorClass.EXHAUSTED, lastError = head.lastError ?: "head-deadline-exceeded",
|
errorClass = ErrorClass.EXHAUSTED,
|
||||||
|
lastError = head.lastError ?: "head-deadline-exceeded",
|
||||||
|
attempts = head.attempts,
|
||||||
)
|
)
|
||||||
|
backfill.attempt(head.msgId)
|
||||||
} else {
|
} else {
|
||||||
sleepQuietly(Duration.between(Instant.now(), head.nextAttemptAt))
|
sleepQuietly(Duration.between(clock.instant(), head.nextAttemptAt))
|
||||||
}
|
}
|
||||||
// PENDING、或 FAILED 退避已到期:交处理入口(内部有边界化失败迁移与 attempts 守卫)
|
// PENDING、或 FAILED 退避已到期:交处理入口(内部有边界化失败迁移与 attempts 守卫)
|
||||||
else -> processor.processOne(head)
|
else -> processor.processOne(head)
|
||||||
@@ -75,7 +81,7 @@ class Pump(
|
|||||||
|
|
||||||
private fun poisoned(head: ProcState): Boolean =
|
private fun poisoned(head: ProcState): Boolean =
|
||||||
head.attempts >= props.pipeline.maxAttempts ||
|
head.attempts >= props.pipeline.maxAttempts ||
|
||||||
Duration.between(head.updatedAt, Instant.now()) > props.pipeline.headDeadline
|
Duration.between(head.updatedAt, clock.instant()) > props.pipeline.headDeadline
|
||||||
|
|
||||||
private fun sleepQuietly(d: Duration) {
|
private fun sleepQuietly(d: Duration) {
|
||||||
if (!d.isNegative && !d.isZero) Thread.sleep(d.toMillis().coerceAtLeast(1))
|
if (!d.isNegative && !d.isZero) Thread.sleep(d.toMillis().coerceAtLeast(1))
|
||||||
@@ -83,7 +89,7 @@ class Pump(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* processOne:解码 → 绑定 → 处理器(事务内决策+落库)→ 终态迁移 → 回填。
|
* processOne:解码 → 绑定 → 处理器(事务内决策 + 落库 + 终态 + 回填意图)→ 提交后最佳努力回填。
|
||||||
* 边界化失败迁移(ProcFailure):任何意外异常归于本条 head,FAILED(INFRA)+退避,不穿出杀泵;
|
* 边界化失败迁移(ProcFailure):任何意外异常归于本条 head,FAILED(INFRA)+退避,不穿出杀泵;
|
||||||
* MALFORMED / PROTOCOL 直接 DEAD 不重试(docs/design.md §2.3 错误分类)。
|
* MALFORMED / PROTOCOL 直接 DEAD 不重试(docs/design.md §2.3 错误分类)。
|
||||||
*/
|
*/
|
||||||
@@ -97,14 +103,14 @@ class MessageProcessor(
|
|||||||
private val fdelProcessor: FdelProcessor,
|
private val fdelProcessor: FdelProcessor,
|
||||||
private val adftProcessor: AdftProcessor,
|
private val adftProcessor: AdftProcessor,
|
||||||
private val procFailure: ProcFailure,
|
private val procFailure: ProcFailure,
|
||||||
|
private val backfill: BackfillService,
|
||||||
private val props: PipelineProps,
|
private val props: PipelineProps,
|
||||||
private val backfillTodo: BackfillTodoRepository? = null,
|
|
||||||
) {
|
) {
|
||||||
private val log = org.slf4j.LoggerFactory.getLogger(MessageProcessor::class.java)
|
private val log = org.slf4j.LoggerFactory.getLogger(MessageProcessor::class.java)
|
||||||
|
|
||||||
fun processOne(head: ProcState) {
|
fun processOne(head: ProcState) {
|
||||||
TraceLog.withTrace(head.msgId) {
|
TraceLog.withTrace(head.msgId) {
|
||||||
try {
|
val terminal = try {
|
||||||
processInternal(head)
|
processInternal(head)
|
||||||
} catch (e: InterruptedException) {
|
} catch (e: InterruptedException) {
|
||||||
Thread.currentThread().interrupt()
|
Thread.currentThread().interrupt()
|
||||||
@@ -114,21 +120,30 @@ class MessageProcessor(
|
|||||||
log.warn("processOne unexpected failure msgId={} ec=INFRA msg={}", head.msgId, e.message ?: e.javaClass.simpleName)
|
log.warn("processOne unexpected failure msgId={} ec=INFRA msg={}", head.msgId, e.message ?: e.javaClass.simpleName)
|
||||||
procFailure.fail(head, ErrorClass.INFRA, e.message ?: e.javaClass.simpleName)
|
procFailure.fail(head, ErrorClass.INFRA, e.message ?: e.javaClass.simpleName)
|
||||||
}
|
}
|
||||||
|
// 终态已提交:立即尝试一次回填;失败留待回填扫描按退避重试(意图已在终态事务内登记)。
|
||||||
|
// 中间态(PENDING/FAILED)不适用(message-lifecycle §5.2)。
|
||||||
|
if (terminal) backfill.attempt(head.msgId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun processInternal(head: ProcState) {
|
/** @return 是否已达终态(终态才允许回填信箱标记) */
|
||||||
|
private fun processInternal(head: ProcState): Boolean {
|
||||||
// 守卫:手工/遗留 FAILED 行若 attempts 已达上限,直接终态(防止退避到期后无限重试)
|
// 守卫:手工/遗留 FAILED 行若 attempts 已达上限,直接终态(防止退避到期后无限重试)
|
||||||
if (head.state == ProcStatus.FAILED && procFailure.scheduler.exhausted(head.attempts)) {
|
if (head.state == ProcStatus.FAILED && procFailure.scheduler.exhausted(head.attempts)) {
|
||||||
log.error("head exhausted at entry -> DEAD msgId={} attempts={}", head.msgId, head.attempts)
|
log.error("head exhausted at entry -> DEAD msgId={} attempts={}", head.msgId, head.attempts)
|
||||||
procState.update(head.msgId, ProcStatus.DEAD, errorClass = ErrorClass.EXHAUSTED, lastError = head.lastError ?: "max-attempts")
|
procState.markTerminal(
|
||||||
return
|
head.msgId, ProcStatus.DEAD,
|
||||||
|
errorClass = ErrorClass.EXHAUSTED,
|
||||||
|
lastError = head.lastError ?: "max-attempts",
|
||||||
|
attempts = head.attempts,
|
||||||
|
)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
val raw = inbox.rawOf(head.msgId) ?: run {
|
val raw = inbox.rawOf(head.msgId)
|
||||||
|
if (raw == null) {
|
||||||
log.error("raw missing -> DEAD(MALFORMED) msgId={}", head.msgId)
|
log.error("raw missing -> DEAD(MALFORMED) msgId={}", head.msgId)
|
||||||
procState.update(head.msgId, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED, lastError = "raw-missing")
|
return deadMalformed(head, "raw-missing")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
val decoded = when (val r = codec.decode(raw)) {
|
val decoded = when (val r = codec.decode(raw)) {
|
||||||
is com.gzzn.omms.msgexchange.codec.DecodeResult.Ok -> r.message
|
is com.gzzn.omms.msgexchange.codec.DecodeResult.Ok -> r.message
|
||||||
@@ -136,12 +151,10 @@ class MessageProcessor(
|
|||||||
// MALFORMED(报文非法)→ DEAD 不重试;CODEC_ERROR(可随 codec 修复重放)→ FAILED 退避
|
// MALFORMED(报文非法)→ DEAD 不重试;CODEC_ERROR(可随 codec 修复重放)→ FAILED 退避
|
||||||
if (r.failure.errorClass == ErrorClass.MALFORMED) {
|
if (r.failure.errorClass == ErrorClass.MALFORMED) {
|
||||||
log.error("decode MALFORMED -> DEAD msgId={} detail={}", head.msgId, r.failure.detail)
|
log.error("decode MALFORMED -> DEAD msgId={} detail={}", head.msgId, r.failure.detail)
|
||||||
procState.update(head.msgId, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED, lastError = r.failure.detail)
|
return deadMalformed(head, r.failure.detail)
|
||||||
} else {
|
|
||||||
log.warn("decode {} -> FAILED msgId={} detail={}", r.failure.errorClass, head.msgId, r.failure.detail)
|
|
||||||
procFailure.fail(head, r.failure.errorClass, r.failure.detail)
|
|
||||||
}
|
}
|
||||||
return
|
log.warn("decode {} -> FAILED msgId={} detail={}", r.failure.errorClass, head.msgId, r.failure.detail)
|
||||||
|
return procFailure.fail(head, r.failure.errorClass, r.failure.detail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,9 +164,8 @@ class MessageProcessor(
|
|||||||
if (!procState.tryBindIdentity(head.msgId, identity)) {
|
if (!procState.tryBindIdentity(head.msgId, identity)) {
|
||||||
val owner = procState.ownerOfIdentity(identity) ?: -1L
|
val owner = procState.ownerOfIdentity(identity) ?: -1L
|
||||||
log.info("duplicate-of:{} -> SKIPPED msgId={}", owner, head.msgId)
|
log.info("duplicate-of:{} -> SKIPPED msgId={}", owner, head.msgId)
|
||||||
procState.update(head.msgId, ProcStatus.SKIPPED, lastError = "duplicate-of:$owner")
|
procState.markTerminal(head.msgId, ProcStatus.SKIPPED, lastError = "duplicate-of:$owner")
|
||||||
compensateBackfill(head, decoded)
|
return true
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,93 +173,52 @@ class MessageProcessor(
|
|||||||
val result: ApplyResult = when (val kind = decoded.kind) {
|
val result: ApplyResult = when (val kind = decoded.kind) {
|
||||||
is MsgKind.Schd -> {
|
is MsgKind.Schd -> {
|
||||||
val body = decoded.body as? ScheduleBody
|
val body = decoded.body as? ScheduleBody
|
||||||
if (body == null) {
|
if (body == null) return deadMalformed(head, "missing-schd-body")
|
||||||
deadMalformed(head, "missing-schd-body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
when (kind.subtype) {
|
when (kind.subtype) {
|
||||||
MsgKind.SchdSubtype.DNLD, MsgKind.SchdSubtype.RESP ->
|
MsgKind.SchdSubtype.DNLD, MsgKind.SchdSubtype.RESP ->
|
||||||
scheduleProcessor.applyScheduleRecords(head, decoded)
|
scheduleProcessor.applyScheduleRecords(head, decoded)
|
||||||
MsgKind.SchdSubtype.ADFT -> {
|
MsgKind.SchdSubtype.ADFT -> {
|
||||||
val record = body.records.singleOrNull()
|
val record = body.records.singleOrNull()
|
||||||
if (record == null) {
|
if (record == null) return deadMalformed(head, "adft-needs-single-fltr")
|
||||||
deadMalformed(head, "adft-needs-single-fltr")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
adftProcessor.apply(head, decoded, record)
|
adftProcessor.apply(head, decoded, record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MsgKind.Fdel -> {
|
MsgKind.Fdel -> {
|
||||||
val payload = decoded.body as? FlopPayload
|
val payload = decoded.body as? FlopPayload
|
||||||
if (payload == null) {
|
if (payload == null) return deadMalformed(head, "missing-fdel-flid")
|
||||||
deadMalformed(head, "missing-fdel-flid")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fdelProcessor.apply(head, decoded, payload)
|
fdelProcessor.apply(head, decoded, payload)
|
||||||
}
|
}
|
||||||
is MsgKind.Flop -> {
|
is MsgKind.Flop -> {
|
||||||
val payload = decoded.body as? FlopPayload
|
val payload = decoded.body as? FlopPayload
|
||||||
if (payload == null) {
|
if (payload == null) return deadMalformed(head, "missing-flop-body")
|
||||||
deadMalformed(head, "missing-flop-body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
flopProcessor.apply(head, decoded, payload)
|
flopProcessor.apply(head, decoded, payload)
|
||||||
}
|
}
|
||||||
is MsgKind.Unsupported -> {
|
is MsgKind.Unsupported -> {
|
||||||
// design.md §2.3:未支持类型 → FAILED(UNSUPPORTED) 退避重试,达阈值转 DEAD;绝不写终态
|
// design.md §2.3:未支持类型 → FAILED(UNSUPPORTED) 退避重试,达阈值转 DEAD;绝不写终态
|
||||||
log.warn("unsupported type -> FAILED(UNSUPPORTED) msgId={} tag={}", head.msgId, kind.tag)
|
log.warn("unsupported type -> FAILED(UNSUPPORTED) msgId={} tag={}", head.msgId, kind.tag)
|
||||||
procFailure.fail(head, ErrorClass.UNSUPPORTED, "no-handler:${kind.tag}")
|
return procFailure.fail(head, ErrorClass.UNSUPPORTED, "no-handler:${kind.tag}")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when (result) {
|
if (result is ApplyResult.DeadProtocol) {
|
||||||
is ApplyResult.Succeeded, ApplyResult.ReplaySkipped ->
|
// design.md §2.3:整包拒绝 DEAD(PROTOCOL),立即释放队头,交人工确认
|
||||||
procState.update(head.msgId, ProcStatus.SUCCEEDED)
|
log.error("DEAD(PROTOCOL) msgId={} reason={} flags={}", head.msgId, result.reason, result.flags)
|
||||||
is ApplyResult.DeadProtocol -> {
|
procState.markTerminal(
|
||||||
// design.md §2.3:整包拒绝 DEAD(PROTOCOL),立即释放队头,交人工确认
|
head.msgId, ProcStatus.DEAD,
|
||||||
log.error("DEAD(PROTOCOL) msgId={} reason={} flags={}", head.msgId, result.reason, result.flags)
|
errorClass = ErrorClass.PROTOCOL,
|
||||||
procState.update(head.msgId, ProcStatus.DEAD, errorClass = ErrorClass.PROTOCOL, lastError = result.reason.take(1000))
|
lastError = result.reason.take(1000),
|
||||||
compensateBackfill(head, decoded) // 拒绝包同样要回填信箱,防止反复轮询
|
)
|
||||||
return
|
return true
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
backfill(head, decoded)
|
// Succeeded / ReplaySkipped:SUCCEEDED 终态与回填意图已由处理器在自己的事务内落库
|
||||||
log.info("SUCCEEDED msgId={} kind={}", head.msgId, decoded.typeTag)
|
log.info("SUCCEEDED msgId={} kind={}", head.msgId, decoded.typeTag)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** design.md §3.3/§6.1:提交后回填共享信箱;失败不得把 SUCCEEDED 改回 FAILED,待办已事务内预登记。 */
|
private fun deadMalformed(head: ProcState, detail: String): Boolean {
|
||||||
private fun backfill(head: ProcState, decoded: DecodedMessage) {
|
procState.markTerminal(head.msgId, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED, lastError = detail)
|
||||||
try {
|
return true
|
||||||
inbox.backfillOnSuccess(head.msgId, decoded.meta.sndr, decoded.meta.type, decoded.meta.styp, decoded.meta.seqn)
|
|
||||||
backfillTodo?.delete(head.msgId)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.error("backfill failed after SUCCEEDED msgId={} (todo pre-registered, sweep will retry)", head.msgId, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** SKIPPED/DEAD(PROTOCOL) 包的回填(无预登记待办):失败落补偿待办。 */
|
|
||||||
private fun compensateBackfill(head: ProcState, decoded: DecodedMessage) {
|
|
||||||
try {
|
|
||||||
inbox.backfillOnSuccess(head.msgId, decoded.meta.sndr, decoded.meta.type, decoded.meta.styp, decoded.meta.seqn)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.error("backfill failed msgId={} (compensation required)", head.msgId, e)
|
|
||||||
backfillTodo?.record(
|
|
||||||
BackfillTodoRepository.BackfillTask(
|
|
||||||
msgId = head.msgId,
|
|
||||||
sndr = decoded.meta.sndr,
|
|
||||||
type = decoded.meta.type,
|
|
||||||
styp = decoded.meta.styp,
|
|
||||||
seqn = decoded.meta.seqn,
|
|
||||||
),
|
|
||||||
e.message ?: e.javaClass.simpleName,
|
|
||||||
) ?: log.warn("no backfill-todo repository bound; compensation NOT persisted msgId={}", head.msgId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deadMalformed(head: ProcState, detail: String) {
|
|
||||||
procState.update(head.msgId, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED, lastError = detail)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.gzzn.omms.msgexchange.config.OperationDayProps
|
|||||||
import com.gzzn.omms.msgexchange.domain.DecodedMessage
|
import com.gzzn.omms.msgexchange.domain.DecodedMessage
|
||||||
import com.gzzn.omms.msgexchange.domain.MsgEvent
|
import com.gzzn.omms.msgexchange.domain.MsgEvent
|
||||||
import com.gzzn.omms.msgexchange.domain.ProcState
|
import com.gzzn.omms.msgexchange.domain.ProcState
|
||||||
|
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
||||||
import com.gzzn.omms.msgexchange.domain.SnapshotFlag
|
import com.gzzn.omms.msgexchange.domain.SnapshotFlag
|
||||||
import com.gzzn.omms.msgexchange.domain.SnapshotLogEntry
|
import com.gzzn.omms.msgexchange.domain.SnapshotLogEntry
|
||||||
import com.gzzn.omms.msgexchange.domain.SnapshotResult
|
import com.gzzn.omms.msgexchange.domain.SnapshotResult
|
||||||
@@ -16,7 +17,6 @@ import com.gzzn.omms.msgexchange.domain.flight.FlightState
|
|||||||
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
|
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
|
||||||
import com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord
|
import com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord
|
||||||
import com.gzzn.omms.msgexchange.domain.flight.SnapshotValidation
|
import com.gzzn.omms.msgexchange.domain.flight.SnapshotValidation
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
|
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
|
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
|
import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
|
||||||
@@ -29,7 +29,7 @@ import java.time.Instant
|
|||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
|
|
||||||
/** 处理器执行结果——终态迁移由 MessageProcessor 统一落库(docs/design.md §2.3)。 */
|
/** 处理器执行结果——终态与回填意图由处理器在自己的业务事务内落库(message-lifecycle §2/§4)。 */
|
||||||
sealed interface ApplyResult {
|
sealed interface ApplyResult {
|
||||||
/** 业务成功(含幂等成功)。 */
|
/** 业务成功(含幂等成功)。 */
|
||||||
data object Succeeded : ApplyResult
|
data object Succeeded : ApplyResult
|
||||||
@@ -57,7 +57,6 @@ class ScheduleProcessor(
|
|||||||
private val flightState: FlightStateRepository,
|
private val flightState: FlightStateRepository,
|
||||||
private val msgEvents: MsgEventRepository,
|
private val msgEvents: MsgEventRepository,
|
||||||
private val snapshotLog: SnapshotLogRepository,
|
private val snapshotLog: SnapshotLogRepository,
|
||||||
private val backfillTodo: BackfillTodoRepository?,
|
|
||||||
operationDayProps: OperationDayProps,
|
operationDayProps: OperationDayProps,
|
||||||
private val mapper: ObjectMapper,
|
private val mapper: ObjectMapper,
|
||||||
) {
|
) {
|
||||||
@@ -133,14 +132,8 @@ class ScheduleProcessor(
|
|||||||
events += snapshotEvents(next)
|
events += snapshotEvents(next)
|
||||||
}
|
}
|
||||||
if (events.isNotEmpty()) msgEvents.insertAll(events)
|
if (events.isNotEmpty()) msgEvents.insertAll(events)
|
||||||
// 回填待办与业务终态同事务预登记(design.md §3.3/§6.1;提交后由 MessageProcessor 回填并删待办)
|
// 终态与回填意图同一事务(message-lifecycle §2/§4):业务写入、事件、终态、回填意图同提交同回滚
|
||||||
backfillTodo?.record(
|
procState.markTerminal(head.msgId, ProcStatus.SUCCEEDED)
|
||||||
BackfillTodoRepository.BackfillTask(
|
|
||||||
msgId = head.msgId, sndr = msg.meta.sndr, type = msg.meta.type,
|
|
||||||
styp = msg.meta.styp, seqn = msg.meta.seqn,
|
|
||||||
),
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
written
|
written
|
||||||
}
|
}
|
||||||
logSnapshot(head, body, SnapshotResult.COMMITTED, upserted, flags, started)
|
logSnapshot(head, body, SnapshotResult.COMMITTED, upserted, flags, started)
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ msgx:
|
|||||||
backoff-ms: [1000, 2000, 4000, 8000, 16000] # 指数退避,单次封顶 60s
|
backoff-ms: [1000, 2000, 4000, 8000, 16000] # 指数退避,单次封顶 60s
|
||||||
backoff-cap-ms: 60000
|
backoff-cap-ms: 60000
|
||||||
head-deadline: 10m # 队头滞留上界 = 最坏 HOL 时长(毒丸升级)
|
head-deadline: 10m # 队头滞留上界 = 最坏 HOL 时长(毒丸升级)
|
||||||
|
max-commit-delay: 5m # §5.1 空洞老化:W+1 空洞超过该时延判定为永久(Q2 最大提交时延)
|
||||||
|
overdue-backfill: 30d # §5.2 超期补写期限 R:≥ 人工重放期限 + 人工处置期限(Q6)
|
||||||
|
backfill-batch: 100 # 回填扫描单批条数
|
||||||
autostart: false # U07:启动即拉起 Pump/Dispatcher 循环;需真实仓储或 msgx.stubs=true 才开启(dev 见 application-dev.yml)
|
autostart: false # U07:启动即拉起 Pump/Dispatcher 循环;需真实仓储或 msgx.stubs=true 才开启(dev 见 application-dev.yml)
|
||||||
schd:
|
schd:
|
||||||
flush-period: 3s # KEEP 现役推送节律
|
flush-period: 3s # KEEP 现役推送节律
|
||||||
@@ -41,8 +44,9 @@ micronaut:
|
|||||||
port: 8080
|
port: 8080
|
||||||
# 管理端点(U03/N32):/env、/beans 默认 sensitive;仅开发/影子环境放开——见 application-dev.yml
|
# 管理端点(U03/N32):/env、/beans 默认 sensitive;仅开发/影子环境放开——见 application-dev.yml
|
||||||
|
|
||||||
# 自有 PostgreSQL:全部内部状态(PIPELINE_LOCK/PROC_STATE/MSG_EVENT/REQ_TRACK/BACKFILL_TODO/
|
# 自有 PostgreSQL:全部内部状态(PIPELINE_LOCK/PROC_STATE/MSG_EVENT/REQ_TRACK/INBOX_CURSOR/
|
||||||
# FLIGHT_SCHD 及明细表/SCHD_SNAP_LOG;迁移 db/migration/V1__flight_state_baseline.sql)。
|
# FLIGHT_SCHD 及明细表/SCHD_SNAP_LOG;迁移 db/migration/V1__flight_state_baseline.sql +
|
||||||
|
# V2__inbox_lifecycle.sql——V2 起回填意图并入 PROC_STATE,BACKFILL_TODO 已下线)。
|
||||||
# 数据层实装前 enabled=false(stub 模式不建连)。
|
# 数据层实装前 enabled=false(stub 模式不建连)。
|
||||||
datasources:
|
datasources:
|
||||||
default:
|
default:
|
||||||
@@ -62,6 +66,7 @@ flyway:
|
|||||||
# 处理回填;出站写 COUTMSGS;不建表/schema,ACM2-12)。驱动/依赖与信箱适配层
|
# 处理回填;出站写 COUTMSGS;不建表/schema,ACM2-12)。驱动/依赖与信箱适配层
|
||||||
# (CminmsgMailbox/OutboxMailbox)随 U05 批次引入。
|
# (CminmsgMailbox/OutboxMailbox)随 U05 批次引入。
|
||||||
mailbox:
|
mailbox:
|
||||||
|
processed-value: PROCESSED # §5.2 处理标记写入值(Q7:值集与写权限以库方契约为准)
|
||||||
shared-mysql:
|
shared-mysql:
|
||||||
enabled: false
|
enabled: false
|
||||||
url: ${MSGX_MAILBOX_URL}
|
url: ${MSGX_MAILBOX_URL}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- V2:信箱生命周期边界层(权威设计:docs/message-lifecycle.md §2/§4/§5.1/§5.2)
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 三处契约级修正,均只动自有 PG(共享 MySQL 不建表、不改结构,红线不破):
|
||||||
|
-- 1. 消费水位 W 落库(INBOX_CURSOR):只随新 ID 成功入队推进、遇空洞即停,
|
||||||
|
-- 与入队同事务——中断后 W 未前进,重扫即补建(§4 第一行);
|
||||||
|
-- HOLE_SINCE 支撑"空洞老化",避免一次自增回滚空位永久停摆水位(§5.1)。
|
||||||
|
-- 2. 回填事实并入 PROC_STATE:终态与回填意图同一条 UPDATE 落下,
|
||||||
|
-- 消除"业务已提交、回填待办未记"与"待办二次落账失败"两个崩溃窗口(§4);
|
||||||
|
-- BACKFILL_TODO 随之下线(它承载的 sndr/type/styp/seqn 从未参与回填,
|
||||||
|
-- 共享信箱回填只需要 MSG_ID——§5.2 补写同样如此)。
|
||||||
|
-- 3. 超期补写判据 RECEIVED_AT(§5.2 的 R)与 OPS-2「最老未处理信龄」锚点。
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- ① 消费水位:单行游标(CURSOR_ID 恒为 1)
|
||||||
|
CREATE TABLE INBOX_CURSOR (
|
||||||
|
CURSOR_ID INT NOT NULL PRIMARY KEY, -- 恒为 1
|
||||||
|
COMMITTED_UP_TO BIGINT NOT NULL, -- 水位 W:连续上界,无空洞
|
||||||
|
HOLE_SINCE TIMESTAMP(6) WITH TIME ZONE, -- W+1 处空洞首次观测时刻;无空洞为 NULL
|
||||||
|
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL
|
||||||
|
);
|
||||||
|
-- 初值 0:首轮从最小 ID 起重扫全部信箱行,入队幂等(ON CONFLICT DO NOTHING),
|
||||||
|
-- 上线即自愈既有"已入队未回填"造成的窗口污染。
|
||||||
|
INSERT INTO INBOX_CURSOR (CURSOR_ID, COMMITTED_UP_TO, HOLE_SINCE, UPDATED_AT) VALUES (1, 0, NULL, now());
|
||||||
|
|
||||||
|
-- ② PROC_STATE:接收时间 + 回填事实(替代 BACKFILL_TODO)
|
||||||
|
ALTER TABLE PROC_STATE ADD COLUMN RECEIVED_AT TIMESTAMP(6) WITH TIME ZONE;
|
||||||
|
ALTER TABLE PROC_STATE ADD COLUMN BACKFILL_AT TIMESTAMP(6) WITH TIME ZONE; -- 非空 = 已确认信箱行持有标记
|
||||||
|
ALTER TABLE PROC_STATE ADD COLUMN BACKFILL_NEXT_AT TIMESTAMP(6) WITH TIME ZONE; -- 非空 = 待回填(终态同事务登记)
|
||||||
|
ALTER TABLE PROC_STATE ADD COLUMN BACKFILL_ATTEMPTS INT NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE PROC_STATE ADD COLUMN BACKFILL_ERROR VARCHAR(512);
|
||||||
|
|
||||||
|
-- 存量行接收时间:无法跨库回读 DATE_RECEIVED,以入队时间为下界(§5.2 判据只会偏晚,不会提前补写)
|
||||||
|
UPDATE PROC_STATE SET RECEIVED_AT = UPDATED_AT WHERE RECEIVED_AT IS NULL;
|
||||||
|
|
||||||
|
-- 存量终态行补登记回填意图:立即交给回填扫描(已持有标记的行由空标守卫返回 0 行,幂等)
|
||||||
|
UPDATE PROC_STATE SET BACKFILL_NEXT_AT = now()
|
||||||
|
WHERE STATE IN ('SUCCEEDED', 'SKIPPED', 'DEAD') AND BACKFILL_AT IS NULL;
|
||||||
|
|
||||||
|
-- 回填扫描索引:仅覆盖未确认标记的行
|
||||||
|
CREATE INDEX idx_proc_backfill_due ON PROC_STATE (BACKFILL_NEXT_AT) WHERE BACKFILL_AT IS NULL;
|
||||||
|
|
||||||
|
-- ③ BACKFILL_TODO 下线(回填意图已并入 PROC_STATE,机制收敛为一套;索引随表一并释放)
|
||||||
|
DROP TABLE BACKFILL_TODO;
|
||||||
@@ -45,6 +45,7 @@ class PipelineSmokeTest {
|
|||||||
fun cleanStubs() {
|
fun cleanStubs() {
|
||||||
ctx.getBean(StubProcState::class.java).clear()
|
ctx.getBean(StubProcState::class.java).clear()
|
||||||
ctx.getBean(com.gzzn.omms.msgexchange.infra.stub.StubInbox::class.java).clear()
|
ctx.getBean(com.gzzn.omms.msgexchange.infra.stub.StubInbox::class.java).clear()
|
||||||
|
ctx.getBean(com.gzzn.omms.msgexchange.infra.stub.StubInboxCursor::class.java).clear()
|
||||||
ctx.getBean(StubMsgEvents::class.java).clear()
|
ctx.getBean(StubMsgEvents::class.java).clear()
|
||||||
ctx.getBean(StubDeliveryPort::class.java).clear()
|
ctx.getBean(StubDeliveryPort::class.java).clear()
|
||||||
}
|
}
|
||||||
@@ -100,6 +101,30 @@ class PipelineSmokeTest {
|
|||||||
assertEquals(0, reopened.attempts)
|
assertEquals(0, reopened.attempts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回归(message-lifecycle §5.2/§5.3):死信到达终态时同时登记回填意图并立即回填——
|
||||||
|
* 否则永不回填的行会永久占据发现窗口,累积到批大小后收报整体停摆。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `dead letter reaches terminal state, gets marked and cannot block later discovery`() {
|
||||||
|
val inbox = ctx.getBean(com.gzzn.omms.msgexchange.infra.stub.StubInbox::class.java)
|
||||||
|
val proc = ctx.getBean(StubProcState::class.java)
|
||||||
|
val dead = inbox.simulateExternalWrite("<MSG/>")
|
||||||
|
ctx.getBean(com.gzzn.omms.msgexchange.ingress.InboxPoller::class.java).pollOnce()
|
||||||
|
|
||||||
|
pump.tick() // 解码 MALFORMED → DEAD + 回填意图(同一条 UPDATE)→ 提交后立即回填
|
||||||
|
|
||||||
|
val row = proc.find(dead)!!
|
||||||
|
assertEquals(ProcStatus.DEAD, row.state)
|
||||||
|
assertEquals(ErrorClass.MALFORMED, row.errorClass)
|
||||||
|
assertNotNull(row.backfillAt)
|
||||||
|
assertTrue(inbox.isMarked(dead))
|
||||||
|
|
||||||
|
val fresh = inbox.simulateExternalWrite("<MSG/>")
|
||||||
|
ctx.getBean(com.gzzn.omms.msgexchange.ingress.InboxPoller::class.java).pollOnce()
|
||||||
|
assertNotNull(proc.find(fresh)) // 死信不阻断后续发现
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `dispatcher flushes schd batch through stub port`() {
|
fun `dispatcher flushes schd batch through stub port`() {
|
||||||
val events = ctx.getBean(StubMsgEvents::class.java)
|
val events = ctx.getBean(StubMsgEvents::class.java)
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
package com.gzzn.omms.msgexchange.infra.health
|
package com.gzzn.omms.msgexchange.infra.health
|
||||||
|
|
||||||
|
import com.gzzn.omms.msgexchange.MutableClock
|
||||||
import com.gzzn.omms.msgexchange.delivery.DeliveryPort
|
import com.gzzn.omms.msgexchange.delivery.DeliveryPort
|
||||||
|
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.InboxCursorRepository
|
||||||
|
import com.gzzn.omms.msgexchange.infra.stub.StubInbox
|
||||||
|
import com.gzzn.omms.msgexchange.infra.stub.StubInboxCursor
|
||||||
|
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
|
||||||
import io.micronaut.health.HealthStatus
|
import io.micronaut.health.HealthStatus
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
@@ -32,4 +38,35 @@ class HealthIndicatorsTest {
|
|||||||
assertEquals(HealthStatus.DOWN, kafkaHealth(ThrowingPort()).status)
|
assertEquals(HealthStatus.DOWN, kafkaHealth(ThrowingPort()).status)
|
||||||
assertEquals(HealthStatus.DOWN, kafkaHealth(null).status) // bean 缺失
|
assertEquals(HealthStatus.DOWN, kafkaHealth(null).status) // bean 缺失
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** OPS-2(message-lifecycle §5.3):积压、最老未处理信龄、未回填终态与水位滞后。 */
|
||||||
|
@Test
|
||||||
|
fun `inbox lifecycle reports backlog, oldest age, unmarked terminals and watermark lag`() {
|
||||||
|
val proc = StubProcState()
|
||||||
|
val inbox = StubInbox().apply { clear() }
|
||||||
|
val cursor = StubInboxCursor()
|
||||||
|
val oldest = MutableClock.BASE.minusSeconds(3600)
|
||||||
|
proc.insertIfAbsent(1L, oldest)
|
||||||
|
proc.insertIfAbsent(2L, MutableClock.BASE)
|
||||||
|
proc.markTerminal(2L, ProcStatus.SUCCEEDED)
|
||||||
|
val newest = inbox.insertRaw("<MSG/>")
|
||||||
|
cursor.save(InboxCursorRepository.Cursor(committedUpTo = newest - 2))
|
||||||
|
|
||||||
|
val result = lifecycleHealth(proc, cursor, inbox, now = MutableClock.BASE)
|
||||||
|
|
||||||
|
assertEquals(HealthStatus.UP, result.status)
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val details = result.details as Map<String, Any>
|
||||||
|
assertEquals(1, details["backlog"])
|
||||||
|
assertEquals(3600L, details["oldestUnprocessedSeconds"])
|
||||||
|
assertEquals(1, details["unmarkedTerminal"])
|
||||||
|
assertEquals(2L, details["watermarkLag"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `inbox lifecycle stays up when ports are not bound`() {
|
||||||
|
val result = lifecycleHealth(procState = null, cursor = null, mailbox = null)
|
||||||
|
|
||||||
|
assertEquals(HealthStatus.UP, result.status) // 可用性由依赖自身指示器承担
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-10
@@ -9,10 +9,12 @@ import org.junit.jupiter.api.Test
|
|||||||
import java.sql.DriverManager
|
import java.sql.DriverManager
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flyway 迁移引擎端到端验证(docs/flight-state.md §2 权威模型 + design.md §2.1):
|
* Flyway 迁移引擎端到端验证(docs/flight-state.md §2 权威模型 + design.md §2.1 +
|
||||||
* 1. V1__flight_state_baseline.sql 在真实 PostgreSQL 上自动迁移成功;
|
* message-lifecycle.md §5.1/§5.2):
|
||||||
|
* 1. V1 基线 + V2 信箱生命周期迁移在真实 PostgreSQL 上自动成功;
|
||||||
* 2. flyway_schema_history 落库且 success = true;
|
* 2. flyway_schema_history 落库且 success = true;
|
||||||
* 3. 决策层/管道层/留痕层全表就绪;PIPELINE_LOCK 单行种子就位。
|
* 3. 决策层/管道层/留痕层全表就绪;PIPELINE_LOCK 与 INBOX_CURSOR 单行种子就位;
|
||||||
|
* 4. V2 收敛结果成立:回填事实并入 PROC_STATE,BACKFILL_TODO 下线。
|
||||||
*/
|
*/
|
||||||
class FlywayMigrationTest {
|
class FlywayMigrationTest {
|
||||||
|
|
||||||
@@ -20,11 +22,11 @@ class FlywayMigrationTest {
|
|||||||
"pipeline_lock", "proc_state", "flight_schd",
|
"pipeline_lock", "proc_state", "flight_schd",
|
||||||
"flight_gate", "flight_checkin", "flight_belt", "flight_stand_plan", "flight_chute",
|
"flight_gate", "flight_checkin", "flight_belt", "flight_stand_plan", "flight_chute",
|
||||||
"flight_delay", "flight_bridge_op", "flight_chock_op", "flight_route_point",
|
"flight_delay", "flight_bridge_op", "flight_chock_op", "flight_route_point",
|
||||||
"msg_event", "req_track", "backfill_todo", "schd_snap_log",
|
"msg_event", "req_track", "inbox_cursor", "schd_snap_log",
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Flyway migrates flight-state baseline onto real PostgreSQL`() {
|
fun `Flyway migrates flight-state baseline and inbox lifecycle onto real PostgreSQL`() {
|
||||||
assumeTrue(PgTestSupport.canConnect(), PgTestSupport.skipMessage())
|
assumeTrue(PgTestSupport.canConnect(), PgTestSupport.skipMessage())
|
||||||
val url = PgTestSupport.jdbcUrl
|
val url = PgTestSupport.jdbcUrl
|
||||||
val user = PgTestSupport.user
|
val user = PgTestSupport.user
|
||||||
@@ -39,7 +41,7 @@ class FlywayMigrationTest {
|
|||||||
|
|
||||||
DriverManager.getConnection(url, user, pass).use { conn ->
|
DriverManager.getConnection(url, user, pass).use { conn ->
|
||||||
conn.createStatement().use { stmt ->
|
conn.createStatement().use { stmt ->
|
||||||
// 迁移记录:单基线 V1
|
// 迁移记录:V1 基线 + V2 信箱生命周期
|
||||||
stmt.executeQuery(
|
stmt.executeQuery(
|
||||||
"SELECT version, script, success FROM flyway_schema_history ORDER BY installed_rank ASC",
|
"SELECT version, script, success FROM flyway_schema_history ORDER BY installed_rank ASC",
|
||||||
).use { rs ->
|
).use { rs ->
|
||||||
@@ -47,13 +49,15 @@ class FlywayMigrationTest {
|
|||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
records.add(Triple(rs.getString("version"), rs.getString("script"), rs.getBoolean("success")))
|
records.add(Triple(rs.getString("version"), rs.getString("script"), rs.getBoolean("success")))
|
||||||
}
|
}
|
||||||
assertTrue(records.isNotEmpty(), "flyway_schema_history must record migrations")
|
assertTrue(records.size >= 2, "flyway_schema_history must record both migrations")
|
||||||
assertEquals("1", records[0].first)
|
assertEquals("1", records[0].first)
|
||||||
assertEquals("V1__flight_state_baseline.sql", records[0].second)
|
assertEquals("V1__flight_state_baseline.sql", records[0].second)
|
||||||
assertTrue(records[0].third)
|
assertEquals("2", records[1].first)
|
||||||
|
assertEquals("V2__inbox_lifecycle.sql", records[1].second)
|
||||||
|
assertTrue(records.all { it.third })
|
||||||
}
|
}
|
||||||
|
|
||||||
// §3.2 全表就绪
|
// 全表就绪(V2 后 backfill_todo 下线)
|
||||||
stmt.executeQuery(
|
stmt.executeQuery(
|
||||||
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'",
|
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'",
|
||||||
).use { rs ->
|
).use { rs ->
|
||||||
@@ -61,6 +65,7 @@ class FlywayMigrationTest {
|
|||||||
while (rs.next()) tables.add(rs.getString("table_name"))
|
while (rs.next()) tables.add(rs.getString("table_name"))
|
||||||
val missing = expectedTables - tables
|
val missing = expectedTables - tables
|
||||||
assertTrue(missing.isEmpty(), "missing tables: $missing")
|
assertTrue(missing.isEmpty(), "missing tables: $missing")
|
||||||
|
assertTrue("backfill_todo" !in tables, "BACKFILL_TODO 已由 PROC_STATE 回填列取代")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 身份不变量基础结构:FLIGHT_SCHD 主键 + STATE 列
|
// 身份不变量基础结构:FLIGHT_SCHD 主键 + STATE 列
|
||||||
@@ -73,11 +78,31 @@ class FlywayMigrationTest {
|
|||||||
assertEquals(setOf("flid", "operation_day", "state", "state_version", "last_msg_id"), cols)
|
assertEquals(setOf("flid", "operation_day", "state", "state_version", "last_msg_id"), cols)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PIPELINE_LOCK 单行种子(§3.2)
|
// 回填事实并入 PROC_STATE(§5.2 判据 + 回填待办)
|
||||||
|
stmt.executeQuery(
|
||||||
|
"SELECT column_name FROM information_schema.columns WHERE table_name = 'proc_state' " +
|
||||||
|
"AND column_name IN ('received_at', 'backfill_at', 'backfill_next_at', " +
|
||||||
|
"'backfill_attempts', 'backfill_error')",
|
||||||
|
).use { rs ->
|
||||||
|
val cols = mutableSetOf<String>()
|
||||||
|
while (rs.next()) cols.add(rs.getString("column_name"))
|
||||||
|
assertEquals(
|
||||||
|
setOf("received_at", "backfill_at", "backfill_next_at", "backfill_attempts", "backfill_error"),
|
||||||
|
cols,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PIPELINE_LOCK 与 INBOX_CURSOR 单行种子(§3.2 / §5.1)
|
||||||
stmt.executeQuery("SELECT count(*) FROM pipeline_lock WHERE lock_id = 1").use { rs ->
|
stmt.executeQuery("SELECT count(*) FROM pipeline_lock WHERE lock_id = 1").use { rs ->
|
||||||
assertTrue(rs.next())
|
assertTrue(rs.next())
|
||||||
assertEquals(1, rs.getInt(1))
|
assertEquals(1, rs.getInt(1))
|
||||||
}
|
}
|
||||||
|
stmt.executeQuery("SELECT committed_up_to, hole_since FROM inbox_cursor WHERE cursor_id = 1").use { rs ->
|
||||||
|
assertTrue(rs.next(), "INBOX_CURSOR 单行种子必须就位")
|
||||||
|
assertEquals(0L, rs.getLong("committed_up_to"))
|
||||||
|
rs.getTimestamp("hole_since")
|
||||||
|
assertTrue(rs.wasNull(), "初始无空洞观测")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+221
@@ -0,0 +1,221 @@
|
|||||||
|
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
|
||||||
|
|
||||||
|
import com.gzzn.omms.msgexchange.domain.ErrorClass
|
||||||
|
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.InboxCursorRepository
|
||||||
|
import org.h2.jdbcx.JdbcDataSource
|
||||||
|
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.assertTrue
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.sql.Connection
|
||||||
|
import java.sql.Timestamp
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.sql.DataSource
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 信箱生命周期 SQL 语义(message-lifecycle §4/§5.1/§5.2/§11)在真实 JDBC 上的验证。
|
||||||
|
* 以 H2 的 PostgreSQL 兼容模式承载与 V1+V2 等价的表结构——不依赖 docker/外接库,
|
||||||
|
* 覆盖 PG 侧(终态与回填意图同体、到期/超期筛选、积压观测、水位游标)与 MySQL 侧
|
||||||
|
* (区间发现、标记单调)的实际语句行为。
|
||||||
|
*
|
||||||
|
* 说明:H2 不支持 `INSERT ... ON CONFLICT DO NOTHING`,
|
||||||
|
* [JdbcProcStateRepository.insertIfAbsent] 的入队幂等由 PG 语义与
|
||||||
|
* `InboxPollerTest`(重复轮询不再入队)分别保证。
|
||||||
|
*/
|
||||||
|
class InboxLifecycleJdbcSqlTest {
|
||||||
|
|
||||||
|
private lateinit var ds: DataSource
|
||||||
|
private lateinit var proc: JdbcProcStateRepository
|
||||||
|
private lateinit var mailbox: JdbcCminmsgInboxRepository
|
||||||
|
private lateinit var cursor: JdbcInboxCursorRepository
|
||||||
|
|
||||||
|
private val t0: Instant = Instant.parse("2026-09-08T03:00:00Z")
|
||||||
|
private val overdue: Instant = t0.minus(Duration.ofDays(31))
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
ds = JdbcDataSource().apply {
|
||||||
|
setURL("jdbc:h2:mem:inbox-${UUID.randomUUID()};DB_CLOSE_DELAY=-1;MODE=PostgreSQL")
|
||||||
|
setUser("sa")
|
||||||
|
setPassword("")
|
||||||
|
}
|
||||||
|
ds.connection.use { conn ->
|
||||||
|
conn.createStatement().use { st ->
|
||||||
|
st.execute(PROC_STATE_DDL)
|
||||||
|
st.execute(CURSOR_DDL)
|
||||||
|
st.execute(MAILBOX_DDL)
|
||||||
|
st.execute(
|
||||||
|
"INSERT INTO inbox_cursor (cursor_id, committed_up_to, hole_since, updated_at) " +
|
||||||
|
"VALUES (1, 0, NULL, CURRENT_TIMESTAMP)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
proc = JdbcProcStateRepository(ds)
|
||||||
|
mailbox = JdbcCminmsgInboxRepository(ds)
|
||||||
|
cursor = JdbcInboxCursorRepository(ds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `terminal write carries the backfill intent in the same statement`() {
|
||||||
|
seed(11L, t0)
|
||||||
|
|
||||||
|
proc.markTerminal(11L, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED, lastError = "raw-missing", now = t0)
|
||||||
|
|
||||||
|
val row = proc.find(11L)!!
|
||||||
|
assertEquals(ProcStatus.DEAD, row.state)
|
||||||
|
assertEquals(ErrorClass.MALFORMED, row.errorClass)
|
||||||
|
assertEquals(t0, row.backfillNextAt) // 终态与回填意图同一条 UPDATE(§2/§4)
|
||||||
|
assertNull(row.backfillAt)
|
||||||
|
assertEquals(t0, row.receivedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `backfill failure is recorded on the same row without touching the terminal state`() {
|
||||||
|
seed(11L, t0)
|
||||||
|
proc.markTerminal(11L, ProcStatus.SUCCEEDED, now = t0)
|
||||||
|
|
||||||
|
proc.recordBackfillFailure(11L, "mysql-down", attempts = 2, nextAttemptAt = t0.plusSeconds(120), now = t0)
|
||||||
|
|
||||||
|
val row = proc.find(11L)!!
|
||||||
|
assertEquals(ProcStatus.SUCCEEDED, row.state) // §11 终态不可逆
|
||||||
|
assertEquals(2, row.backfillAttempts)
|
||||||
|
assertEquals(t0.plusSeconds(120), row.backfillNextAt)
|
||||||
|
assertEquals("mysql-down", row.backfillError)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `due query honours due time, the overdue deadline and terminal-only filter`() {
|
||||||
|
// ① 到期
|
||||||
|
seed(1L, t0)
|
||||||
|
proc.markTerminal(1L, ProcStatus.SUCCEEDED, now = t0)
|
||||||
|
// ② 未到期(退避推后)
|
||||||
|
seed(2L, t0)
|
||||||
|
proc.markTerminal(2L, ProcStatus.SUCCEEDED, now = t0)
|
||||||
|
proc.recordBackfillFailure(2L, "mysql-down", 1, t0.plus(Duration.ofMinutes(15)), t0)
|
||||||
|
// ③ 未到期但接收时间已达超期期限 R(§5.2 覆盖退避)
|
||||||
|
seed(3L, overdue)
|
||||||
|
proc.markTerminal(3L, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED, now = t0)
|
||||||
|
proc.recordBackfillFailure(3L, "mysql-down", 1, t0.plus(Duration.ofMinutes(15)), t0)
|
||||||
|
// ④ 中间态:永不补写(§5.2)
|
||||||
|
seed(4L, overdue)
|
||||||
|
// ⑤ 已确认标记
|
||||||
|
seed(5L, t0)
|
||||||
|
proc.markTerminal(5L, ProcStatus.SUCCEEDED, now = t0)
|
||||||
|
proc.markBackfilled(5L, t0)
|
||||||
|
|
||||||
|
val due = proc.findBackfillDue(t0, t0.minus(Duration.ofDays(30)), limit = 100).map { it.msgId }
|
||||||
|
|
||||||
|
assertEquals(listOf(1L, 3L), due)
|
||||||
|
assertEquals(listOf(1L), proc.findBackfillDue(t0, t0.minus(Duration.ofDays(30)), limit = 1).map { it.msgId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `backlog reports unfinished messages, oldest receive time and unmarked terminals`() {
|
||||||
|
seed(1L, overdue)
|
||||||
|
seed(2L, t0)
|
||||||
|
proc.markTerminal(2L, ProcStatus.SUCCEEDED, now = t0)
|
||||||
|
|
||||||
|
val backlog = proc.backlog()
|
||||||
|
|
||||||
|
assertEquals(1, backlog.unfinished)
|
||||||
|
assertEquals(overdue, backlog.oldestReceivedAt) // OPS-2 最老未处理信龄锚点
|
||||||
|
assertEquals(1, backlog.unmarkedTerminal)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `watermark round trips with and without a pending hole`() {
|
||||||
|
assertEquals(InboxCursorRepository.Cursor(0L, null), cursor.load())
|
||||||
|
|
||||||
|
cursor.save(InboxCursorRepository.Cursor(42L, t0))
|
||||||
|
|
||||||
|
assertEquals(InboxCursorRepository.Cursor(42L, t0), cursor.load())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `mailbox range read ignores processing marks and marking never overwrites`() {
|
||||||
|
val first = mailbox.insertRaw("<A/>")
|
||||||
|
val second = mailbox.insertRaw("<B/>")
|
||||||
|
val third = mailbox.insertRaw("<C/>")
|
||||||
|
|
||||||
|
// 发现按 ID 区间;处理标记不参与扫描谓词
|
||||||
|
assertEquals(listOf(first, second, third), mailbox.readRange(0L, 50).map { it.msgId })
|
||||||
|
assertNotNull(mailbox.readRange(0L, 50).first().receivedAt)
|
||||||
|
assertEquals(listOf(second, third), mailbox.readRange(first, 50).map { it.msgId })
|
||||||
|
assertEquals(third, mailbox.maxId())
|
||||||
|
|
||||||
|
assertTrue(mailbox.markProcessedIfUnmarked(second, "PROCESSED"))
|
||||||
|
assertFalse(mailbox.markProcessedIfUnmarked(second, "OTHER")) // §11 只把空标写为已处理
|
||||||
|
assertEquals("PROCESSED", statusOf(second))
|
||||||
|
// 已标记行仍出现在区间读结果中(发现与标记彻底解耦)
|
||||||
|
assertEquals(listOf(first, second, third), mailbox.readRange(0L, 50).map { it.msgId })
|
||||||
|
assertFalse(mailbox.markProcessedIfUnmarked(999L, "PROCESSED"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun seed(msgId: Long, receivedAt: Instant?, state: ProcStatus = ProcStatus.PENDING) {
|
||||||
|
ds.connection.use { conn: Connection ->
|
||||||
|
conn.prepareStatement(
|
||||||
|
"INSERT INTO proc_state (msg_id, state, received_at, backfill_attempts, updated_at) VALUES (?, ?, ?, 0, ?)",
|
||||||
|
).use { ps ->
|
||||||
|
ps.setLong(1, msgId)
|
||||||
|
ps.setString(2, state.name)
|
||||||
|
ps.setTimestamp(3, receivedAt?.let { Timestamp.from(it) })
|
||||||
|
ps.setTimestamp(4, Timestamp.from(t0))
|
||||||
|
ps.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun statusOf(msgId: Long): String? =
|
||||||
|
ds.connection.use { conn: Connection ->
|
||||||
|
conn.prepareStatement("SELECT CMINMSGS_STATUS FROM cminmsgs WHERE CMINMSGS_ID = ?").use { ps ->
|
||||||
|
ps.setLong(1, msgId)
|
||||||
|
ps.executeQuery().use { rs -> if (rs.next()) rs.getString(1) else null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PROC_STATE_DDL = """
|
||||||
|
CREATE TABLE proc_state (
|
||||||
|
msg_id BIGINT PRIMARY KEY,
|
||||||
|
state VARCHAR(16) NOT NULL,
|
||||||
|
identity_key VARCHAR(200),
|
||||||
|
attempts INT NOT NULL DEFAULT 0,
|
||||||
|
next_attempt_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
error_class VARCHAR(20),
|
||||||
|
last_error VARCHAR(1000),
|
||||||
|
received_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
backfill_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
backfill_next_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
backfill_attempts INT NOT NULL DEFAULT 0,
|
||||||
|
backfill_error VARCHAR(512),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
CONSTRAINT uk_proc_identity UNIQUE (identity_key)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
const val CURSOR_DDL = """
|
||||||
|
CREATE TABLE inbox_cursor (
|
||||||
|
cursor_id INT PRIMARY KEY,
|
||||||
|
committed_up_to BIGINT NOT NULL,
|
||||||
|
hole_since TIMESTAMP WITH TIME ZONE,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
const val MAILBOX_DDL = """
|
||||||
|
CREATE TABLE cminmsgs (
|
||||||
|
CMINMSGS_ID BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
CMINMSGS_CLOB_MSG CLOB,
|
||||||
|
CMINMSGS_DATE_RECEIVED TIMESTAMP,
|
||||||
|
CMINMSGS_DATE_PROCESSED TIMESTAMP,
|
||||||
|
CMINMSGS_STATUS VARCHAR(32)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,8 +23,7 @@ class ReplayServiceTest {
|
|||||||
rows[id] = ProcState(id, status, identityKey = "k$id", attempts = 3, errorClass = ec, lastError = "x")
|
rows[id] = ProcState(id, status, identityKey = "k$id", attempts = 3, errorClass = ec, lastError = "x")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun insert(msgId: Long, state: ProcStatus) = Unit
|
override fun insertIfAbsent(msgId: Long, receivedAt: Instant?): Boolean = rows.putIfAbsent(msgId, ProcState(msgId, ProcStatus.PENDING, receivedAt = receivedAt)) == null
|
||||||
override fun exists(msgId: Long): Boolean = rows.containsKey(msgId)
|
|
||||||
override fun find(msgId: Long): ProcState? = rows[msgId]
|
override fun find(msgId: Long): ProcState? = rows[msgId]
|
||||||
override fun findSuccessTerminal(msgId: Long): Boolean = rows[msgId]?.state == ProcStatus.SUCCEEDED
|
override fun findSuccessTerminal(msgId: Long): Boolean = rows[msgId]?.state == ProcStatus.SUCCEEDED
|
||||||
override fun headUnfinished(): ProcState? = null
|
override fun headUnfinished(): ProcState? = null
|
||||||
@@ -35,6 +34,20 @@ class ReplayServiceTest {
|
|||||||
errorClass: ErrorClass?, lastError: String?,
|
errorClass: ErrorClass?, lastError: String?,
|
||||||
) = Unit
|
) = Unit
|
||||||
|
|
||||||
|
override fun markTerminal(
|
||||||
|
msgId: Long, state: ProcStatus, errorClass: ErrorClass?, lastError: String?,
|
||||||
|
attempts: Int?, now: Instant,
|
||||||
|
) = Unit
|
||||||
|
|
||||||
|
override fun markBackfilled(msgId: Long, now: Instant) = Unit
|
||||||
|
|
||||||
|
override fun recordBackfillFailure(msgId: Long, error: String?, attempts: Int, nextAttemptAt: Instant, now: Instant) = Unit
|
||||||
|
|
||||||
|
override fun findBackfillDue(now: Instant, overdueBefore: Instant, limit: Int) =
|
||||||
|
emptyList<com.gzzn.omms.msgexchange.infra.persistence.BackfillDue>()
|
||||||
|
|
||||||
|
override fun backlog() = com.gzzn.omms.msgexchange.infra.persistence.Backlog(0, null, 0)
|
||||||
|
|
||||||
override fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int {
|
override fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int {
|
||||||
requeueCalls += errorClasses
|
requeueCalls += errorClasses
|
||||||
var n = 0
|
var n = 0
|
||||||
|
|||||||
@@ -1,47 +1,120 @@
|
|||||||
package com.gzzn.omms.msgexchange.ingress
|
package com.gzzn.omms.msgexchange.ingress
|
||||||
|
|
||||||
|
import com.gzzn.omms.msgexchange.config.PipelineProps
|
||||||
import com.gzzn.omms.msgexchange.domain.ErrorClass
|
import com.gzzn.omms.msgexchange.domain.ErrorClass
|
||||||
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
||||||
import com.gzzn.omms.msgexchange.infra.stub.StubInbox
|
import com.gzzn.omms.msgexchange.infra.stub.StubInbox
|
||||||
|
import com.gzzn.omms.msgexchange.infra.stub.StubInboxCursor
|
||||||
|
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineTx
|
||||||
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
|
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
|
||||||
import com.gzzn.omms.msgexchange.processing.Pump
|
|
||||||
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
|
|
||||||
import jakarta.inject.Inject
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
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.assertNotNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
import org.junit.jupiter.api.BeforeEach
|
import org.junit.jupiter.api.BeforeEach
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
/** 主路径:JDBC 轮询语义(stub 下 InboxPoller + 外部写信箱模拟)。 */
|
/**
|
||||||
@MicronautTest
|
* 收报发现权不变量(docs/message-lifecycle.md §5.1/§5.3 + architecture.md §5 严格 FIFO):
|
||||||
|
* - 扫描按 ID 区间,**不受处理标记影响**:终态而未回填的行不得占据批次、不得阻断新信发现;
|
||||||
|
* - 水位只随成功入队推进,且与入队同事务(中断后由重扫补建);
|
||||||
|
* - 遇空洞即停(较小 ID 未入队时不得被后续消息越过);空洞老化后放行(水位不得永久停摆);
|
||||||
|
* - 收报层不写处理标记。
|
||||||
|
*/
|
||||||
class InboxPollerTest {
|
class InboxPollerTest {
|
||||||
|
|
||||||
@Inject lateinit var poller: InboxPoller
|
private val t0: Instant = Instant.parse("2026-09-08T03:00:00Z")
|
||||||
@Inject lateinit var pump: Pump
|
private val props = PipelineProps()
|
||||||
@Inject lateinit var stubInbox: StubInbox
|
|
||||||
@Inject lateinit var stubProc: StubProcState
|
private lateinit var inbox: StubInbox
|
||||||
|
private lateinit var proc: StubProcState
|
||||||
|
private lateinit var cursor: StubInboxCursor
|
||||||
|
private lateinit var poller: InboxPoller
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
fun clean() {
|
fun setUp() {
|
||||||
stubInbox.clear()
|
inbox = StubInbox().apply { clear() }
|
||||||
stubProc.clear()
|
proc = StubProcState().apply { clear() }
|
||||||
|
cursor = StubInboxCursor().apply { clear() }
|
||||||
|
poller = InboxPoller(inbox, proc, cursor, StubPipelineTx(), props)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `poller enqueues externally written mailbox rows`() {
|
fun `external rows are enqueued in id order and advance the watermark without marking the mailbox`() {
|
||||||
val id = stubInbox.simulateExternalWrite("<MSG/>")
|
val first = inbox.simulateExternalWrite("<MSG/>")
|
||||||
assertEquals(1, poller.pollOnce())
|
val second = inbox.simulateExternalWrite("<MSG/>")
|
||||||
assertNotNull(stubProc.snapshotOf(id))
|
|
||||||
assertEquals(ProcStatus.PENDING, stubProc.snapshotOf(id)!!.state)
|
assertEquals(2, poller.pollOnce(t0))
|
||||||
|
|
||||||
|
assertEquals(ProcStatus.PENDING, proc.find(first)!!.state)
|
||||||
|
assertEquals(ProcStatus.PENDING, proc.find(second)!!.state)
|
||||||
|
assertEquals(second, cursor.cursor.committedUpTo)
|
||||||
|
assertNull(cursor.cursor.holeSince)
|
||||||
|
// §5.3:消化阶段只写自有 PG,不触碰信箱标记
|
||||||
|
assertFalse(inbox.isMarked(first))
|
||||||
|
assertEquals(0, poller.pollOnce(t0)) // 重复扫描幂等
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回归(US-01 条目 3 / §5.3「积压挡批」):终态且永不回填的行(解码失败死信等)曾占满
|
||||||
|
* 有限批次使收报整体停摆——发现必须与处理标记彻底解耦。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `terminal rows without a mailbox mark do not block discovery of later messages`() {
|
||||||
|
props.pipeline.claimBatch = 3
|
||||||
|
val dead = (1..3).map { inbox.simulateExternalWrite("<MSG/>") }
|
||||||
|
assertEquals(3, poller.pollOnce(t0))
|
||||||
|
dead.forEach {
|
||||||
|
proc.markTerminal(it, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED, lastError = "raw-missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
val fresh = inbox.simulateExternalWrite("<MSG/>")
|
||||||
|
|
||||||
|
assertEquals(1, poller.pollOnce(t0))
|
||||||
|
assertEquals(ProcStatus.PENDING, proc.find(fresh)!!.state)
|
||||||
|
assertEquals(fresh, cursor.cursor.committedUpTo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `poller is idempotent for already enqueued rows`() {
|
fun `watermark stops at a hole so later ids cannot overtake a missing smaller id`() {
|
||||||
val id = stubInbox.simulateExternalWrite("<MSG/>")
|
val first = inbox.simulateExternalWrite("<MSG/>")
|
||||||
poller.pollOnce()
|
val hole = inbox.simulateExternalWrite("<MSG/>")
|
||||||
assertEquals(0, poller.pollOnce())
|
val afterHole = inbox.simulateExternalWrite("<MSG/>")
|
||||||
pump.tick()
|
inbox.removeRow(hole)
|
||||||
assertEquals(ProcStatus.DEAD, stubProc.snapshotOf(id)!!.state)
|
|
||||||
assertEquals(ErrorClass.MALFORMED, stubProc.snapshotOf(id)!!.errorClass)
|
assertEquals(1, poller.pollOnce(t0))
|
||||||
|
|
||||||
|
assertEquals(first, cursor.cursor.committedUpTo)
|
||||||
|
assertNotNull(cursor.cursor.holeSince)
|
||||||
|
assertNull(proc.find(afterHole)) // 不得越过空洞入队(FIFO)
|
||||||
|
assertEquals(0, poller.pollOnce(t0.plusSeconds(60))) // 宽限期内水位不推进
|
||||||
|
assertEquals(first, cursor.cursor.committedUpTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an aged hole is released and later ids resume enqueuing`() {
|
||||||
|
val hole = inbox.simulateExternalWrite("<MSG/>")
|
||||||
|
val afterHole = inbox.simulateExternalWrite("<MSG/>")
|
||||||
|
inbox.removeRow(hole)
|
||||||
|
poller.pollOnce(t0) // 记录空洞观测时刻
|
||||||
|
|
||||||
|
val agedOut = t0.plus(props.pipeline.maxCommitDelay)
|
||||||
|
assertEquals(0, poller.pollOnce(agedOut)) // 空洞判永久:推进水位但不越过入队
|
||||||
|
|
||||||
|
assertNull(cursor.cursor.holeSince)
|
||||||
|
assertEquals(afterHole - 1, cursor.cursor.committedUpTo)
|
||||||
|
assertEquals(1, poller.pollOnce(agedOut)) // 下一轮恢复发现
|
||||||
|
assertEquals(afterHole, cursor.cursor.committedUpTo)
|
||||||
|
assertNotNull(proc.find(afterHole))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `compat http path and poller do not double enqueue the same message`() {
|
||||||
|
val receipt = InboxService(inbox, proc).accept("<MSG/>", t0)
|
||||||
|
|
||||||
|
assertEquals(0, poller.pollOnce(t0))
|
||||||
|
assertEquals(receipt.msgId, cursor.cursor.committedUpTo) // 已在 PG:读取进度照常推进
|
||||||
|
assertNotNull(proc.find(receipt.msgId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
package com.gzzn.omms.msgexchange.jobs
|
|
||||||
|
|
||||||
import com.gzzn.omms.msgexchange.config.PipelineProps
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
|
||||||
import com.gzzn.omms.msgexchange.infra.stub.StubBackfillTodo
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import java.time.Duration
|
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* v2 §5(ACM2-29 P2-3):提交后回填补偿——失败落库、到期重试、成功即清、退避推后。
|
|
||||||
* 重启对账语义:待办持久化后,任意时刻新的 sweep 都能继续推进(不依赖内存状态)。
|
|
||||||
*/
|
|
||||||
class BackfillSweepJobTest {
|
|
||||||
|
|
||||||
private class FakeInbox(var fail: Boolean = false) : CminmsgInboxRepository {
|
|
||||||
val backfilled = mutableSetOf<Long>()
|
|
||||||
override fun insertRaw(rawXml: String) = 1L
|
|
||||||
override fun rawOf(cminmsgsId: Long): String? = null
|
|
||||||
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = emptyList()
|
|
||||||
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) {
|
|
||||||
if (fail) throw IllegalStateException("mysql-down")
|
|
||||||
backfilled += cminmsgsId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val t0: Instant = Instant.parse("2026-09-08T03:00:00Z")
|
|
||||||
|
|
||||||
private fun task(id: Long = 901L) = BackfillTodoRepository.BackfillTask(id, "AODB", "SCHD", "DNLD", 9L)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `due task is retried and removed after successful backfill`() {
|
|
||||||
val todo = StubBackfillTodo()
|
|
||||||
val inbox = FakeInbox(fail = false)
|
|
||||||
todo.record(task(), "mysql-down", t0)
|
|
||||||
val job = BackfillSweepJob(todo, inbox, PipelineProps())
|
|
||||||
|
|
||||||
val outcome = job.sweep(t0.plusSeconds(60))
|
|
||||||
|
|
||||||
assertEquals(BackfillSweepJob.SweepOutcome(inspected = 1, succeeded = 1, failed = 0), outcome)
|
|
||||||
assertEquals(0, todo.count())
|
|
||||||
assertTrue(901L in inbox.backfilled)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `failed retry increments attempts and applies exponential backoff`() {
|
|
||||||
val todo = StubBackfillTodo()
|
|
||||||
val inbox = FakeInbox(fail = true)
|
|
||||||
todo.record(task(), "mysql-down", t0)
|
|
||||||
val job = BackfillSweepJob(todo, inbox, PipelineProps())
|
|
||||||
|
|
||||||
val outcome = job.sweep(t0)
|
|
||||||
|
|
||||||
assertEquals(BackfillSweepJob.SweepOutcome(inspected = 1, succeeded = 0, failed = 1), outcome)
|
|
||||||
assertEquals(1, todo.count())
|
|
||||||
assertEquals(1, todo.tasks.values.single().attempts)
|
|
||||||
assertEquals("mysql-down", todo.lastErrorOf(901L))
|
|
||||||
// 一次失败后 30s 内不再到期(退避生效)
|
|
||||||
assertEquals(0, todo.findDue(t0.plusSeconds(29)).size)
|
|
||||||
assertEquals(1, todo.findDue(t0.plusSeconds(30)).size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `recovered inbox completes compensation on a later sweep as if restarted`() {
|
|
||||||
val todo = StubBackfillTodo()
|
|
||||||
val inbox = FakeInbox(fail = true)
|
|
||||||
todo.record(task(), "mysql-down", t0)
|
|
||||||
val job = BackfillSweepJob(todo, inbox, PipelineProps())
|
|
||||||
job.sweep(t0)
|
|
||||||
job.sweep(t0.plusSeconds(30)) // 第二次仍失败 → attempts=2
|
|
||||||
|
|
||||||
inbox.fail = false // 共享信箱恢复(模拟重启后对账)
|
|
||||||
val outcome = job.sweep(t0.plusSeconds(90))
|
|
||||||
|
|
||||||
assertEquals(BackfillSweepJob.SweepOutcome(inspected = 1, succeeded = 1, failed = 0), outcome)
|
|
||||||
assertEquals(0, todo.count())
|
|
||||||
assertTrue(901L in inbox.backfilled)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `backoff delay doubles per attempt and caps at fifteen minutes`() {
|
|
||||||
assertEquals(Duration.ofSeconds(30), BackfillSweepJob.backoffDelayFor(1))
|
|
||||||
assertEquals(Duration.ofMinutes(2), BackfillSweepJob.backoffDelayFor(3))
|
|
||||||
assertEquals(Duration.ofMinutes(15), BackfillSweepJob.backoffDelayFor(10))
|
|
||||||
assertEquals(Duration.ofMinutes(15), BackfillSweepJob.backoffDelayFor(50))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package com.gzzn.omms.msgexchange.processing
|
||||||
|
|
||||||
|
import com.gzzn.omms.msgexchange.config.MailboxProps
|
||||||
|
import com.gzzn.omms.msgexchange.config.PipelineProps
|
||||||
|
import com.gzzn.omms.msgexchange.domain.ErrorClass
|
||||||
|
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.MailboxRow
|
||||||
|
import com.gzzn.omms.msgexchange.infra.stub.StubInbox
|
||||||
|
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
|
||||||
|
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.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.Clock
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回填通道不变量(docs/message-lifecycle.md §3/§4/§5.2/§11):
|
||||||
|
* 终态即刻补写、失败退避重试、超期期限 R 覆盖退避、标记单调只写空标、
|
||||||
|
* 中间态不适用、回填失败不回改终态、重启后扫描不依赖内存状态。
|
||||||
|
*/
|
||||||
|
class BackfillServiceTest {
|
||||||
|
|
||||||
|
private val t0: Instant = Instant.parse("2026-09-08T03:00:00Z")
|
||||||
|
private val props = PipelineProps()
|
||||||
|
|
||||||
|
/** 可注入故障的信箱:验证回填失败路径(§3 失败处理)。 */
|
||||||
|
private class FakeMailbox(var fail: Boolean = false) : CminmsgInboxRepository {
|
||||||
|
val marked = linkedSetOf<Long>()
|
||||||
|
override fun insertRaw(rawXml: String): Long = 1L
|
||||||
|
override fun rawOf(msgId: Long): String? = null
|
||||||
|
override fun readRange(fromExclusive: Long, limit: Int): List<MailboxRow> = emptyList()
|
||||||
|
override fun maxId(): Long? = null
|
||||||
|
override fun markProcessedIfUnmarked(msgId: Long, value: String): Boolean {
|
||||||
|
if (fail) throw IllegalStateException("mysql-down")
|
||||||
|
return marked.add(msgId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun service(proc: StubProcState, mailbox: CminmsgInboxRepository, now: Instant = t0) =
|
||||||
|
BackfillService(proc, mailbox, MailboxProps(), props, Clock.fixed(now, ZoneOffset.UTC))
|
||||||
|
|
||||||
|
/** 终态 + 回填意图(固定时刻,避免依赖真实时钟)。 */
|
||||||
|
private fun succeeded(proc: StubProcState, id: Long) {
|
||||||
|
proc.insertIfAbsent(id, t0)
|
||||||
|
proc.markTerminal(id, ProcStatus.SUCCEEDED, now = t0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `terminal message is marked immediately and the intent is cleared`() {
|
||||||
|
val proc = StubProcState()
|
||||||
|
val inbox = StubInbox().apply { clear() }
|
||||||
|
val id = inbox.insertRaw("<MSG/>")
|
||||||
|
succeeded(proc, id)
|
||||||
|
|
||||||
|
service(proc, inbox).attempt(id)
|
||||||
|
|
||||||
|
assertEquals("PROCESSED", inbox.markOf(id))
|
||||||
|
val row = proc.find(id)!!
|
||||||
|
assertNotNull(row.backfillAt)
|
||||||
|
assertNull(row.backfillNextAt)
|
||||||
|
assertNull(row.backfillError)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `marking is monotonic - an existing value is never overwritten`() {
|
||||||
|
val inbox = StubInbox().apply { clear() }
|
||||||
|
val id = inbox.insertRaw("<MSG/>")
|
||||||
|
|
||||||
|
assertTrue(inbox.markProcessedIfUnmarked(id, "PROCESSED"))
|
||||||
|
assertFalse(inbox.markProcessedIfUnmarked(id, "OTHER")) // §11 只把空标写为已处理
|
||||||
|
assertEquals("PROCESSED", inbox.markOf(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `already marked rows are idempotent and never recorded as failures`() {
|
||||||
|
val proc = StubProcState()
|
||||||
|
val inbox = StubInbox().apply { clear() }
|
||||||
|
val id = inbox.insertRaw("<MSG/>")
|
||||||
|
succeeded(proc, id)
|
||||||
|
|
||||||
|
val backfill = service(proc, inbox)
|
||||||
|
backfill.attempt(id)
|
||||||
|
backfill.attempt(id) // 重复执行无副作用(§5.2 补写只针对空标记)
|
||||||
|
|
||||||
|
assertNull(proc.find(id)!!.backfillError)
|
||||||
|
assertEquals(0, proc.find(id)!!.backfillAttempts)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `failed attempt records backoff and never touches the terminal state`() {
|
||||||
|
val proc = StubProcState()
|
||||||
|
val mailbox = FakeMailbox(fail = true)
|
||||||
|
succeeded(proc, 901L)
|
||||||
|
|
||||||
|
service(proc, mailbox).attempt(901L)
|
||||||
|
|
||||||
|
val row = proc.find(901L)!!
|
||||||
|
assertEquals(ProcStatus.SUCCEEDED, row.state) // §11 终态不可逆
|
||||||
|
assertEquals(1, row.backfillAttempts)
|
||||||
|
assertEquals("mysql-down", row.backfillError)
|
||||||
|
assertEquals(t0.plus(Duration.ofSeconds(30)), row.backfillNextAt)
|
||||||
|
assertNull(row.backfillAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `sweep retries due rows and completes once the mailbox recovers`() {
|
||||||
|
val proc = StubProcState()
|
||||||
|
val mailbox = FakeMailbox(fail = true)
|
||||||
|
succeeded(proc, 901L)
|
||||||
|
val backfill = service(proc, mailbox)
|
||||||
|
|
||||||
|
assertEquals(1, backfill.sweep(t0)) // 到期 → 失败 → 退避
|
||||||
|
assertEquals(0, backfill.sweep(t0.plusSeconds(29))) // 未到期
|
||||||
|
assertEquals(1, backfill.sweep(t0.plusSeconds(30))) // 到期再试 → 仍失败(重启后同样收敛)
|
||||||
|
|
||||||
|
mailbox.fail = false
|
||||||
|
assertEquals(1, backfill.sweep(t0.plusSeconds(90)))
|
||||||
|
|
||||||
|
assertTrue(901L in mailbox.marked)
|
||||||
|
assertNotNull(proc.find(901L)!!.backfillAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** §5.2:超期期限 R 覆盖退避,保证库方清除前提「边界内无未标记行」在有限时间内成立。 */
|
||||||
|
@Test
|
||||||
|
fun `overdue rows bypass the retry backoff`() {
|
||||||
|
val proc = StubProcState()
|
||||||
|
val inbox = StubInbox().apply { clear() }
|
||||||
|
val id = inbox.insertRaw("<MSG/>")
|
||||||
|
val old = t0.minus(props.pipeline.overdueBackfill).minusSeconds(60)
|
||||||
|
proc.insertIfAbsent(id, old) // 接收时间早于 R
|
||||||
|
proc.markTerminal(id, ProcStatus.SUCCEEDED, now = t0)
|
||||||
|
proc.recordBackfillFailure(id, "mysql-down", 5, t0.plus(Duration.ofMinutes(15)), t0) // 退避推到很远之后
|
||||||
|
|
||||||
|
assertEquals(1, service(proc, inbox).sweep(t0))
|
||||||
|
|
||||||
|
assertNotNull(proc.find(id)!!.backfillAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** §5.2:中间态(PENDING / FAILED)不适用超期补写——处理未完成时不打标。 */
|
||||||
|
@Test
|
||||||
|
fun `mid states are never marked even when far past the deadline`() {
|
||||||
|
val proc = StubProcState()
|
||||||
|
val inbox = StubInbox().apply { clear() }
|
||||||
|
val pending = inbox.insertRaw("<MSG/>")
|
||||||
|
val failed = inbox.insertRaw("<MSG/>")
|
||||||
|
val old = t0.minus(props.pipeline.overdueBackfill).minusSeconds(60)
|
||||||
|
proc.insertIfAbsent(pending, old)
|
||||||
|
proc.insertIfAbsent(failed, old)
|
||||||
|
proc.update(failed, ProcStatus.FAILED, errorClass = ErrorClass.INFRA)
|
||||||
|
|
||||||
|
assertEquals(0, service(proc, inbox).sweep(t0))
|
||||||
|
|
||||||
|
assertFalse(inbox.isMarked(pending))
|
||||||
|
assertFalse(inbox.isMarked(failed))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `backoff delay doubles per attempt and caps at fifteen minutes`() {
|
||||||
|
assertEquals(Duration.ofSeconds(30), BackfillService.backoffDelayFor(1))
|
||||||
|
assertEquals(Duration.ofMinutes(2), BackfillService.backoffDelayFor(3))
|
||||||
|
assertEquals(Duration.ofMinutes(15), BackfillService.backoffDelayFor(10))
|
||||||
|
assertEquals(Duration.ofMinutes(15), BackfillService.backoffDelayFor(50))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,12 +13,13 @@ import com.gzzn.omms.msgexchange.domain.ProcStatus
|
|||||||
import com.gzzn.omms.msgexchange.domain.Targets
|
import com.gzzn.omms.msgexchange.domain.Targets
|
||||||
import com.gzzn.omms.msgexchange.domain.flight.FlightState
|
import com.gzzn.omms.msgexchange.domain.flight.FlightState
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
|
import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
|
||||||
import com.gzzn.omms.msgexchange.infra.stub.StubBackfillTodo
|
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
|
||||||
import com.gzzn.omms.msgexchange.infra.stub.StubFlightState
|
import com.gzzn.omms.msgexchange.infra.stub.StubFlightState
|
||||||
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
|
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
|
||||||
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineLock
|
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineLock
|
||||||
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineTx
|
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineTx
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
@@ -59,9 +60,10 @@ class FdelAndAdftProcessorTest {
|
|||||||
fun `active flight deletion publishes single tombstone and keeps details`() {
|
fun `active flight deletion publishes single tombstone and keeps details`() {
|
||||||
val f = flights()
|
val f = flights()
|
||||||
val events = StubMsgEvents()
|
val events = StubMsgEvents()
|
||||||
val todo = StubBackfillTodo()
|
val proc = StubProcState()
|
||||||
|
proc.insertIfAbsent(msgId, null)
|
||||||
|
|
||||||
val result = FdelProcessor(StubPipelineTx(), StubPipelineLock(), f, events, todo, ObjectMapper())
|
val result = FdelProcessor(StubPipelineTx(), StubPipelineLock(), f, events, proc, ObjectMapper())
|
||||||
.apply(head(), msg(), FlopPayload("121"))
|
.apply(head(), msg(), FlopPayload("121"))
|
||||||
|
|
||||||
assertEquals(ApplyResult.Succeeded, result)
|
assertEquals(ApplyResult.Succeeded, result)
|
||||||
@@ -71,14 +73,17 @@ class FdelAndAdftProcessorTest {
|
|||||||
assertEquals(1, tombstones.size)
|
assertEquals(1, tombstones.size)
|
||||||
assertEquals(Targets.KAFKA_SCHD, tombstones.single().target)
|
assertEquals(Targets.KAFKA_SCHD, tombstones.single().target)
|
||||||
assertTrue(tombstones.single().payloadJson.contains("\"deleted\":true"))
|
assertTrue(tombstones.single().payloadJson.contains("\"deleted\":true"))
|
||||||
assertEquals(1, todo.count())
|
// 终态 + 回填意图由处理器在自己的事务内落库(message-lifecycle §2/§4)
|
||||||
|
val row = proc.find(msgId)!!
|
||||||
|
assertEquals(ProcStatus.SUCCEEDED, row.state)
|
||||||
|
assertNotNull(row.backfillNextAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `repeated FDEL is idempotent without version bump or duplicate event`() {
|
fun `repeated FDEL is idempotent without version bump or duplicate event`() {
|
||||||
val f = flights()
|
val f = flights()
|
||||||
val events = StubMsgEvents()
|
val events = StubMsgEvents()
|
||||||
val proc = FdelProcessor(StubPipelineTx(), StubPipelineLock(), f, events, null, ObjectMapper())
|
val proc = FdelProcessor(StubPipelineTx(), StubPipelineLock(), f, events, StubProcState(), ObjectMapper())
|
||||||
|
|
||||||
proc.apply(head(), msg(), FlopPayload("121"))
|
proc.apply(head(), msg(), FlopPayload("121"))
|
||||||
val version = f.findMainRow("121")!!.stateVersion
|
val version = f.findMainRow("121")!!.stateVersion
|
||||||
@@ -94,7 +99,7 @@ class FdelAndAdftProcessorTest {
|
|||||||
val f = StubFlightState()
|
val f = StubFlightState()
|
||||||
val events = StubMsgEvents()
|
val events = StubMsgEvents()
|
||||||
|
|
||||||
val result = FdelProcessor(StubPipelineTx(), StubPipelineLock(), f, events, null, ObjectMapper())
|
val result = FdelProcessor(StubPipelineTx(), StubPipelineLock(), f, events, StubProcState(), ObjectMapper())
|
||||||
.apply(head(), msg(), FlopPayload("999"))
|
.apply(head(), msg(), FlopPayload("999"))
|
||||||
|
|
||||||
assertEquals(ApplyResult.Succeeded, result) // §3.3:迟到/不存在幂等成功
|
assertEquals(ApplyResult.Succeeded, result) // §3.3:迟到/不存在幂等成功
|
||||||
@@ -107,7 +112,7 @@ class FdelAndAdftProcessorTest {
|
|||||||
f.markDeleted("121", msgId = 2, now = java.time.Instant.now())
|
f.markDeleted("121", msgId = 2, now = java.time.Instant.now())
|
||||||
val events = StubMsgEvents()
|
val events = StubMsgEvents()
|
||||||
val adft = AdftProcessor(
|
val adft = AdftProcessor(
|
||||||
StubPipelineTx(), StubPipelineLock(), f, events, null,
|
StubPipelineTx(), StubPipelineLock(), f, events, StubProcState(),
|
||||||
OperationDayProps().apply { zone = "Asia/Shanghai" }, ObjectMapper(),
|
OperationDayProps().apply { zone = "Asia/Shanghai" }, ObjectMapper(),
|
||||||
)
|
)
|
||||||
val record = com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord(
|
val record = com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord(
|
||||||
@@ -128,7 +133,7 @@ class FdelAndAdftProcessorTest {
|
|||||||
val f = StubFlightState()
|
val f = StubFlightState()
|
||||||
val events = StubMsgEvents()
|
val events = StubMsgEvents()
|
||||||
val adft = AdftProcessor(
|
val adft = AdftProcessor(
|
||||||
StubPipelineTx(), StubPipelineLock(), f, events, null,
|
StubPipelineTx(), StubPipelineLock(), f, events, StubProcState(),
|
||||||
OperationDayProps().apply { zone = "Asia/Shanghai" }, ObjectMapper(),
|
OperationDayProps().apply { zone = "Asia/Shanghai" }, ObjectMapper(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -154,7 +159,7 @@ class FdelAndAdftProcessorTest {
|
|||||||
msgId = 1, now = java.time.Instant.now(),
|
msgId = 1, now = java.time.Instant.now(),
|
||||||
)
|
)
|
||||||
val adft = AdftProcessor(
|
val adft = AdftProcessor(
|
||||||
StubPipelineTx(), StubPipelineLock(), f, StubMsgEvents(), null,
|
StubPipelineTx(), StubPipelineLock(), f, StubMsgEvents(), StubProcState(),
|
||||||
OperationDayProps().apply { zone = "Asia/Shanghai" }, ObjectMapper(),
|
OperationDayProps().apply { zone = "Asia/Shanghai" }, ObjectMapper(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import com.gzzn.omms.msgexchange.domain.SnapshotFlag
|
|||||||
import com.gzzn.omms.msgexchange.domain.SnapshotResult
|
import com.gzzn.omms.msgexchange.domain.SnapshotResult
|
||||||
import com.gzzn.omms.msgexchange.domain.Targets
|
import com.gzzn.omms.msgexchange.domain.Targets
|
||||||
import com.gzzn.omms.msgexchange.domain.flight.FlightState
|
import com.gzzn.omms.msgexchange.domain.flight.FlightState
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
|
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
|
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
|
import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
|
||||||
@@ -24,8 +23,8 @@ import com.gzzn.omms.msgexchange.infra.stub.StubFlightState
|
|||||||
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
|
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
|
||||||
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
|
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
|
||||||
import com.gzzn.omms.msgexchange.infra.stub.StubSnapshotLog
|
import com.gzzn.omms.msgexchange.infra.stub.StubSnapshotLog
|
||||||
import com.gzzn.omms.msgexchange.infra.stub.StubBackfillTodo
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
import org.junit.jupiter.api.Assertions.assertNull
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
@@ -70,7 +69,6 @@ class ScheduleProcessorTest {
|
|||||||
flights: StubFlightState = StubFlightState(),
|
flights: StubFlightState = StubFlightState(),
|
||||||
events: StubMsgEvents = StubMsgEvents(),
|
events: StubMsgEvents = StubMsgEvents(),
|
||||||
log: StubSnapshotLog = StubSnapshotLog(),
|
log: StubSnapshotLog = StubSnapshotLog(),
|
||||||
todo: StubBackfillTodo? = StubBackfillTodo(),
|
|
||||||
tx: PipelineTransactionManager? = null,
|
tx: PipelineTransactionManager? = null,
|
||||||
): ScheduleProcessor {
|
): ScheduleProcessor {
|
||||||
val txRunner = tx ?: object : PipelineTransactionManager {
|
val txRunner = tx ?: object : PipelineTransactionManager {
|
||||||
@@ -83,20 +81,20 @@ class ScheduleProcessorTest {
|
|||||||
flightState = flights,
|
flightState = flights,
|
||||||
msgEvents = events,
|
msgEvents = events,
|
||||||
snapshotLog = log,
|
snapshotLog = log,
|
||||||
backfillTodo = todo,
|
|
||||||
operationDayProps = OperationDayProps().apply { zone = "Asia/Shanghai"; cutoffHour = 0 },
|
operationDayProps = OperationDayProps().apply { zone = "Asia/Shanghai"; cutoffHour = 0 },
|
||||||
mapper = ObjectMapper(),
|
mapper = ObjectMapper(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `happy path persists snapshot with version bump, events, snap log and backfill todo`() {
|
fun `happy path persists snapshot, events, terminal state and backfill intent in one transaction`() {
|
||||||
|
val proc = StubProcState()
|
||||||
|
proc.insertIfAbsent(msgId, null)
|
||||||
val flights = StubFlightState()
|
val flights = StubFlightState()
|
||||||
val events = StubMsgEvents()
|
val events = StubMsgEvents()
|
||||||
val log = StubSnapshotLog()
|
val log = StubSnapshotLog()
|
||||||
val todo = StubBackfillTodo()
|
|
||||||
|
|
||||||
val result = processor(flights = flights, events = events, log = log, todo = todo)
|
val result = processor(proc = proc, flights = flights, events = events, log = log)
|
||||||
.applyScheduleRecords(head(), message(makeBody("121" to "15DEC261723")))
|
.applyScheduleRecords(head(), message(makeBody("121" to "15DEC261723")))
|
||||||
|
|
||||||
assertEquals(ApplyResult.Succeeded, result)
|
assertEquals(ApplyResult.Succeeded, result)
|
||||||
@@ -108,13 +106,17 @@ class ScheduleProcessorTest {
|
|||||||
assertEquals(1, log.entries.size)
|
assertEquals(1, log.entries.size)
|
||||||
assertEquals(SnapshotResult.COMMITTED, log.entries.single().result)
|
assertEquals(SnapshotResult.COMMITTED, log.entries.single().result)
|
||||||
assertEquals(1, log.entries.single().upserted)
|
assertEquals(1, log.entries.single().upserted)
|
||||||
assertEquals(1, todo.count()) // design.md §3.3/§6.1 事务内预登记
|
// message-lifecycle §2/§4:终态与回填意图随业务写入在同一事务提交(不依赖主泵补写)
|
||||||
|
val row = proc.find(msgId)!!
|
||||||
|
assertEquals(ProcStatus.SUCCEEDED, row.state)
|
||||||
|
assertNotNull(row.backfillNextAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `replay of succeeded message records idempotent success without writes`() {
|
fun `replay of succeeded message records idempotent success without writes`() {
|
||||||
val proc = StubProcState()
|
val proc = StubProcState()
|
||||||
proc.insert(msgId, ProcStatus.SUCCEEDED)
|
proc.insertIfAbsent(msgId, null)
|
||||||
|
proc.markTerminal(msgId, ProcStatus.SUCCEEDED)
|
||||||
val flights = StubFlightState()
|
val flights = StubFlightState()
|
||||||
val log = StubSnapshotLog()
|
val log = StubSnapshotLog()
|
||||||
|
|
||||||
@@ -190,17 +192,16 @@ class ScheduleProcessorTest {
|
|||||||
val proc = StubProcState()
|
val proc = StubProcState()
|
||||||
val flights = StubFlightState()
|
val flights = StubFlightState()
|
||||||
val log = StubSnapshotLog()
|
val log = StubSnapshotLog()
|
||||||
val todo = StubBackfillTodo()
|
proc.insertIfAbsent(msgId, null)
|
||||||
proc.insert(msgId)
|
val p = processor(proc = proc, flights = flights, log = log, tx = TxRunner { true })
|
||||||
val p = processor(proc = proc, flights = flights, log = log, todo = todo, tx = TxRunner { true })
|
|
||||||
|
|
||||||
// design.md §2.3:数据库/内部故障 → 异常上抛,MessageProcessor 记 FAILED(INFRA) 退避;不写任何终态
|
// design.md §2.3:数据库/内部故障 → 异常上抛,MessageProcessor 记 FAILED(INFRA) 退避;不写任何终态
|
||||||
org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException::class.java) {
|
org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException::class.java) {
|
||||||
p.applyScheduleRecords(head(), message(makeBody("121" to "15DEC261723")))
|
p.applyScheduleRecords(head(), message(makeBody("121" to "15DEC261723")))
|
||||||
}
|
}
|
||||||
assertNull(flights.findMainRow("121"))
|
assertNull(flights.findMainRow("121"))
|
||||||
assertEquals(0, todo.count())
|
|
||||||
assertEquals(0, log.entries.size)
|
assertEquals(0, log.entries.size)
|
||||||
assertEquals(ProcStatus.PENDING, proc.find(msgId)!!.state)
|
assertEquals(ProcStatus.PENDING, proc.find(msgId)!!.state)
|
||||||
|
assertNull(proc.find(msgId)!!.backfillNextAt) // 事务回滚:终态与回填意图都不落库
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
# U07:stubs=true 提供内存仓储使全链路(Controller→Inbox→Pump→Dispatcher)可装配;
|
# U07:stubs=true 提供内存仓储使全链路(Controller→Inbox→Pump→Dispatcher)可装配;
|
||||||
# autostart=false:测试内不自动拉起后台循环(避免泄漏线程),按需手动 tick。
|
# autostart=false:测试内不自动拉起后台循环(避免泄漏线程),按需手动 tick。
|
||||||
# ACM2-12:main 的 datasources.default(自有 PG)默认 enabled=false,测试以 H2 内存替代并显式启用。
|
# ACM2-12:main 的 datasources.default(自有 PG)默认 enabled=false,测试以 H2 内存替代并显式启用。
|
||||||
|
# V2:BackfillService 注入 MailboxProps,其 shared-mysql.* 占位符必须在测试环境可解析
|
||||||
|
# (信箱不启用:enabled=false,仅需占位符取值)。
|
||||||
msgx:
|
msgx:
|
||||||
register-eureka: false
|
register-eureka: false
|
||||||
stubs: true
|
stubs: true
|
||||||
@@ -18,6 +20,15 @@ datasources:
|
|||||||
password: ""
|
password: ""
|
||||||
driver-class-name: org.h2.Driver
|
driver-class-name: org.h2.Driver
|
||||||
|
|
||||||
|
mailbox:
|
||||||
|
processed-value: PROCESSED
|
||||||
|
shared-mysql:
|
||||||
|
enabled: false
|
||||||
|
url: jdbc:mysql://127.0.0.1:3306/cdairport
|
||||||
|
username: msgx_test
|
||||||
|
password: msgx_test
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
|
||||||
flyway:
|
flyway:
|
||||||
datasources:
|
datasources:
|
||||||
default:
|
default:
|
||||||
|
|||||||
Reference in New Issue
Block a user