Files
msgexchange-v2/src/test/kotlin/com/gzzn/omms/msgexchange/ingress/InboxPollerTest.kt
T
windyboy d475feb790 docs(kdoc): 重写信箱生命周期相关 KDoc,改为直白说明并去掉内部编号
原有注释大量引用 §5.1/§5.2/Q2/US-01 这类文档编号和内部简称,跳过了"这段代码在做什么、
为什么这么做",没有读过设计文档的人基本读不懂。本次统一改成先讲清这件事本身、
再说为什么要这样做,编号只在末尾留一处指路。

覆盖本次改动涉及的 24 个 Kotlin 文件(生产 16 个 + 测试 8 个):

- 领域与端口:ProcState(补齐全量字段说明与状态/错误分类逐项注释)、
  ProcStateRepository / InboxCursorRepository / CminmsgInboxRepository 及
  MailboxRow / BackfillDue / Backlog;
- 收报:InboxPoller(把"水位连续、遇缺口停下、缺口老化"用大白话讲透)、
  InboxService、JdbcCminmsgInboxRepository;
- 处理:Pump / MessageProcessor、ScheduleProcessor、DynamicProcessors、
  ProcFailure、JdbcProcStateRepository 与游标实现;
- 回填与观测:BackfillService、InboxLifecycleHealthIndicator、JobRunner;
- 配置与 stub:PipelineProps(三个新增参数说清取值理由)、MailboxProps、
  StubRepositories;
- 测试:8 个测试类改为"这些用例在守哪几条规矩",并保留 H2 不覆盖
  ON CONFLICT 的说明。

术语统一按第一次出现就地解释:水位、处理标记、回填、死信、队头、终态。
纯注释改动;除拆分枚举时按仓库风格补的两个行尾逗号外无代码变更
(已用剥离注释后比对 HEAD 的方式逐文件核对)。测试仍为 78 passed / 1 skipped。
2026-09-10 11:05:55 +08:00

122 lines
5.3 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.ProcStatus
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 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.BeforeEach
import org.junit.jupiter.api.Test
import java.time.Instant
/**
* 收报环节最要紧的几条规矩:
* - 取新消息只看 ID,不看处理标记。处理完却没能回填的行(尤其是永远不回填的死信)
* 不允许占住批次,也不允许挡住后面的新消息——这是曾经的线上隐患;
* - 水位只在成功登记后才推进,而且和登记写在同一个事务里,中断后重扫就能补齐;
* - 遇到 ID 缺口先停下来(可能有更小的消息还没到),缺口等太久则跳过(否则水位永远卡住);
* - 这一层不碰信箱的处理标记,标记留给回填环节写。
*/
class InboxPollerTest {
private val t0: Instant = Instant.parse("2026-09-08T03:00:00Z")
private val props = PipelineProps()
private lateinit var inbox: StubInbox
private lateinit var proc: StubProcState
private lateinit var cursor: StubInboxCursor
private lateinit var poller: InboxPoller
@BeforeEach
fun setUp() {
inbox = StubInbox().apply { clear() }
proc = StubProcState().apply { clear() }
cursor = StubInboxCursor().apply { clear() }
poller = InboxPoller(inbox, proc, cursor, StubPipelineTx(), props)
}
@Test
fun `external rows are enqueued in id order and advance the watermark without marking the mailbox`() {
val first = inbox.simulateExternalWrite("<MSG/>")
val second = inbox.simulateExternalWrite("<MSG/>")
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
fun `watermark stops at a hole so later ids cannot overtake a missing smaller id`() {
val first = inbox.simulateExternalWrite("<MSG/>")
val hole = inbox.simulateExternalWrite("<MSG/>")
val afterHole = inbox.simulateExternalWrite("<MSG/>")
inbox.removeRow(hole)
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))
}
}