feat(runtime): U07 生命周期装配 + stub 装配冒烟(ACM2-10 U07/U01 遗留)

- PipelineLifecycle:ServerStartupEvent 后在专用单线程(msgx-pump/msgx-dispatcher,daemon)
  拉起 Pump/Dispatcher 循环;停机 requestStop+interrupt+join;@Requires(msgx.pipeline.autostart=true)
- PipelineProps.pipeline.autostart(默认 false,生产/测试默认不自动起循环,防泄漏线程)
- stub 装配(infra/stub,@Requires(msgx.stubs=true)):7 仓储内存实装 + StubXmlCodec(未实装
  → CODEC_ERROR 走 FAILED 可重放路径)+ StubRedis + StubDeliveryPort + Holder Factory
- 配置:application-dev.yml(stubs+autostart,/env、/beans 放开,无需 DB 即可 dev 跑通);
  application-test.yml(stubs=true、autostart=false);README 补 dev stub 运行说明
- PipelineSmokeTest(4):等价 /beans 装配核验 + 收报→主泵领取→FAILED(CODEC_ERROR) + U11 重放
  回 PENDING + Dispatcher flush 聚合(stub 用例前清内存状态防上下文复用串扰)
- 全量测试 34 个通过
This commit is contained in:
windyboy
2026-09-06 20:53:14 +08:00
parent 73113779d2
commit f6da0cb782
11 changed files with 518 additions and 7 deletions
+4 -1
View File
@@ -60,11 +60,14 @@ REQ_TRACK / PUMP_JOB / FLIGHT_STATE),并假定 `CMINMSGS`(及其历史表
```bash
./gradlew build # 需网络拉取依赖;内网环境见 gradle.properties 注释
./gradlew test # 纯逻辑单测(identity / schd 聚合 / 配置绑定)
./gradlew test # 纯逻辑单测(identity / schd 聚合 / 配置绑定 / 管道语义
MICRONAUT_ENVIRONMENTS=dev ./gradlew run # dev stub 冒烟:内存仓储 + 启动主泵/投递(无需 DB/Redis/Kafka
```
> 注解处理:Kotlin 侧经 KSP`kotlin-ksp` + `micronaut-inject-kotlin`)生成 Micronaut
> BeanDefinitionU01);若 build 产物缺少 `*$Definition` 类,先检查 KSP 是否生效。
> dev/shadow 冒烟装配:`msgx.stubs=true`(内存仓储/适配层,见 infra/stub+
> `msgx.pipeline.autostart=true`PipelineLifecycle 拉起专用线程,U07);生产默认两者关闭。
## 关联
@@ -0,0 +1,50 @@
package com.gzzn.omms.msgexchange.nextgen
import com.gzzn.omms.msgexchange.nextgen.delivery.Dispatcher
import com.gzzn.omms.msgexchange.nextgen.processing.Pump
import io.micronaut.context.annotation.Requires
import io.micronaut.runtime.event.annotation.EventListener
import io.micronaut.runtime.server.event.ServerStartupEvent
import jakarta.annotation.PreDestroy
import jakarta.inject.Singleton
/**
* U07(T03+N29):管道生命周期装配——服务启动(ServerStartupEvent)后,在各自专用单线程
* msgx-pump / msgx-dispatcher,不占用 Netty event loop)上拉起 Pump 与 Dispatcher 循环;
* 停机时 requestStop + interrupt + join。仅当 `msgx.pipeline.autostart=true` 时装配
* (默认关:需要真实仓储或 msgx.stubs=true 才安全开启)。
*/
@Requires(property = "msgx.pipeline.autostart", value = "true")
@Singleton
class PipelineLifecycle(
private val pump: Pump,
private val dispatcher: Dispatcher,
) {
private val threads = mutableListOf<Thread>()
@Volatile
private var started = false
@EventListener
fun start(event: ServerStartupEvent) {
startIfNeeded()
}
private fun startIfNeeded() {
if (started) return
started = true
threads += spawn("msgx-pump", pump::loop)
threads += spawn("msgx-dispatcher", dispatcher::loop)
}
private fun spawn(name: String, body: () -> Unit): Thread =
Thread.ofPlatform().name(name).daemon(true).start { body() }
@PreDestroy
fun stop() {
pump.stop()
dispatcher.stop()
threads.forEach { it.interrupt() } // 解除 Thread.sleep 阻塞,加速退出
threads.forEach { runCatching { it.join(3000) } }
}
}
@@ -29,6 +29,9 @@ class PipelineProps {
var backoffCapMs: Long = 60_000
var headDeadline: Duration = Duration.ofMinutes(10) // 最坏 HOL 上界(毒丸升级)
/** U07:启动即拉起 Pump/Dispatcher 循环(默认关——需要真实仓储或 msgx.stubs=true 才可安全开启)。 */
var autostart: Boolean = false
/** N28attempt ≤ 0(如 FAILED 未递增 attempts 的行)不得抛异常,取下界=首档退避。 */
fun backoffFor(attempt: Int): Long {
val index = (attempt - 1).coerceAtLeast(0)
@@ -43,6 +43,11 @@ class Dispatcher(
private var lastFlush: Instant = Instant.EPOCH
/** 优雅停机:loop 收尾后退出;线程中断由 Runner 负责。 */
fun stop() {
running = false
}
fun loop() {
while (running) {
try {
@@ -0,0 +1,76 @@
package com.gzzn.omms.msgexchange.nextgen.infra.stub
import com.gzzn.omms.msgexchange.nextgen.codec.DecodeFailure
import com.gzzn.omms.msgexchange.nextgen.codec.DecodeResult
import com.gzzn.omms.msgexchange.nextgen.codec.XmlCodec
import com.gzzn.omms.msgexchange.nextgen.delivery.DeliveryPort
import com.gzzn.omms.msgexchange.nextgen.domain.ErrorClass
import com.gzzn.omms.msgexchange.nextgen.infra.redis.FlightRedisClient
import com.gzzn.omms.msgexchange.nextgen.infra.redis.RedisScript
import com.gzzn.omms.msgexchange.nextgen.processing.CodecHolder
import com.gzzn.omms.msgexchange.nextgen.processing.HandlerHolder
import com.gzzn.omms.msgexchange.nextgen.processing.HandlerRegistry
import io.micronaut.context.annotation.Bean
import io.micronaut.context.annotation.Factory
import io.micronaut.context.annotation.Requires
import jakarta.inject.Singleton
/**
* U07stub 适配层——XmlCodec / Redis 客户端 / DeliveryPort / Handler 装配,仅在 msgx.stubs=true 时生效。
* stub codec 未实装 → 报文 decode 返回 CODEC_ERROR(走 FAILED 可重放路径,链路上可观测),
* 语义见 ACM2-10 U11:不把“未实装”写成报文非法/终态。
*/
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubXmlCodec : XmlCodec {
override fun decode(rawXml: String): DecodeResult =
DecodeResult.Err(DecodeFailure(ErrorClass.CODEC_ERROR, "stub:codec-not-implemented"))
override fun encodeRqrd(kind: String, rangeJson: String): String = ""
}
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubRedis : FlightRedisClient {
val hash = mutableMapOf<String, String>()
val evalCalls = mutableListOf<RedisScript>()
fun clear() { hash.clear(); evalCalls.clear() }
override fun eval(script: RedisScript, setPairs: List<Pair<String, String>>, delFields: List<String>) {
evalCalls += script
when (script) {
RedisScript.SNAPSHOT_REPLACE -> {
hash.putAll(setPairs)
delFields.forEach { hash.remove(it) }
}
RedisScript.BATCH_DELETE -> delFields.forEach { hash.remove(it) }
}
}
override fun hgetAllFlightInfo(): Map<String, String> = hash.toMap()
}
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubDeliveryPort : DeliveryPort {
val sent = mutableListOf<Pair<String, String>>()
fun clear() { sent.clear() }
override fun sendKafka(topic: String, payloadJson: String) { sent += topic to payloadJson }
override fun indexFlightHts(payloadJson: String) = Unit
override fun projectRedis(payloadJson: String) = Unit
}
/** Holder 工厂:CodecHolder/HandlerHolder 由 Micronaut Bean 提供(取代直连构造占位)。 */
@Factory
@Requires(property = "msgx.stubs", value = "true")
class StubHolderFactory {
@Bean
fun codecHolder(codec: StubXmlCodec): CodecHolder = CodecHolder(codec)
@Bean
fun handlerHolder(): HandlerHolder = HandlerHolder(HandlerRegistry(emptyList())) // 阶段 2 前无 Handler 注册
}
@@ -0,0 +1,234 @@
package com.gzzn.omms.msgexchange.nextgen.infra.stub
import com.gzzn.omms.msgexchange.nextgen.domain.ErrorClass
import com.gzzn.omms.msgexchange.nextgen.domain.EventStatus
import com.gzzn.omms.msgexchange.nextgen.domain.MsgEvent
import com.gzzn.omms.msgexchange.nextgen.domain.ProcState
import com.gzzn.omms.msgexchange.nextgen.domain.ProcStatus
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.PumpJobRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.RefDataRepository
import com.gzzn.omms.msgexchange.nextgen.infra.persistence.ReqTrackRepository
import io.micronaut.context.annotation.Requires
import jakarta.inject.Singleton
import java.time.Instant
import java.util.concurrent.atomic.AtomicLong
/**
* U07(U01 遗留 stub):内存仓储装配——仅当 `msgx.stubs=true`dev/影子冒烟)时生效
* @Requires),让「启动 + 收报 + 主泵/投递循环 + /beans 端到端」在无 MySQL/Redis/Kafka 时可跑。
* 与真实 Micronaut Data 实装按模块替换;切换点条件显式,不误入生产。
*/
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubProcState : ProcStateRepository {
private val rows = linkedMapOf<Long, ProcState>()
private val bound = mutableMapOf<String, Long>()
fun clear() { rows.clear(); bound.clear() }
override fun insert(cminmsgsId: Long, state: ProcStatus) {
rows[cminmsgsId] = ProcState(cminmsgsId, state)
}
override fun headUnfinished(): ProcState? =
rows.filterValues { it.state == ProcStatus.PENDING || it.state == ProcStatus.FAILED }
.minByOrNull { it.key }?.value
override fun tryBindIdentity(cminmsgsId: Long, identityKey: String): Boolean {
val owner = bound[identityKey]
if (owner != null && owner != cminmsgsId) return false
bound[identityKey] = cminmsgsId
rows[cminmsgsId] = (rows[cminmsgsId] ?: ProcState(cminmsgsId, ProcStatus.PENDING)).copy(identityKey = identityKey)
return true
}
override fun ownerOfIdentity(identityKey: String): Long? = bound[identityKey]
override fun update(
cminmsgsId: Long, state: ProcStatus, nextAttemptAt: Instant?, attempts: Int?,
errorClass: ErrorClass?, lastError: String?,
) {
val old = rows[cminmsgsId] ?: ProcState(cminmsgsId, state)
rows[cminmsgsId] = old.copy(
state = state,
nextAttemptAt = nextAttemptAt ?: old.nextAttemptAt,
attempts = attempts ?: old.attempts,
errorClass = errorClass ?: old.errorClass,
lastError = lastError ?: old.lastError,
)
}
override fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int {
var n = 0
rows.keys.toList().forEach { id ->
val s = rows[id]!!
if (s.errorClass != null && s.errorClass in errorClasses &&
(s.state == ProcStatus.FAILED || s.state == ProcStatus.DEAD)) {
rows[id] = s.copy(state = ProcStatus.PENDING, attempts = 0, nextAttemptAt = null)
n++
}
}
return n
}
/** 测试/运维观测用:读取当前状态行。 */
fun snapshotOf(cminmsgsId: Long): ProcState? = rows[cminmsgsId]
}
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubInbox : CminmsgInboxRepository {
private val raws = linkedMapOf<Long, String>()
fun clear() { raws.clear() }
private val ids = AtomicLong(0)
override fun insertRaw(rawXml: String): Long {
val id = ids.incrementAndGet()
raws[id] = rawXml
return id
}
override fun rawOf(cminmsgsId: Long): String? = raws[cminmsgsId]
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) {
// 内存 stub:无列可回填,记录即可(后续由真实实装/审计消费)
}
}
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubMsgEvents : MsgEventRepository {
private val rows = linkedMapOf<Long, MsgEvent>()
fun clear() { rows.clear() }
private val ids = AtomicLong(0)
override fun insertAll(events: List<MsgEvent>): List<Long> = events.map { e ->
val id = ids.incrementAndGet()
rows[id] = e.copy(eventId = id)
id
}
override fun headUnsent(target: String): MsgEvent? =
rows.values.filter { it.target == target && it.state == EventStatus.PENDING }
.minByOrNull { it.eventId ?: Long.MAX_VALUE }
override fun claimBatch(target: String, limit: Int): List<MsgEvent> =
rows.values.filter { it.target == target && it.state == EventStatus.PENDING }
.sortedBy { it.eventId ?: Long.MAX_VALUE }
.take(limit)
override fun markSent(eventId: Long) = mutate(eventId) { it.copy(state = EventStatus.SENT) }
override fun markAllSent(eventIds: List<Long>) = eventIds.forEach(::markSent)
override fun scheduleRetry(eventId: Long, nextAttemptAt: Instant, attempts: Int) =
mutate(eventId) { it.copy(state = EventStatus.PENDING, attempts = attempts, nextAttemptAt = nextAttemptAt) }
override fun markDead(eventId: Long, errorClass: ErrorClass, lastError: String, attempts: Int?) =
mutate(eventId) { it.copy(state = EventStatus.DEAD, errorClass = errorClass, lastError = lastError, attempts = attempts ?: it.attempts) }
override fun insertSync(events: List<MsgEvent>) {
insertAll(events)
}
private fun mutate(eventId: Long, f: (MsgEvent) -> MsgEvent) {
val cur = rows[eventId] ?: return
rows[eventId] = f(cur)
}
}
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubPumpJobs : PumpJobRepository {
fun clear() { rows.clear() }
data class JobRow(val job: PumpJobRepository.Job, var state: String, var lastError: String? = null)
private val rows = linkedMapOf<Long, JobRow>()
private val ids = AtomicLong(0)
override fun enqueue(kind: String) {
val id = ids.incrementAndGet()
rows[id] = JobRow(PumpJobRepository.Job(id, kind), "QUEUED")
}
override fun headQueued(): PumpJobRepository.Job? =
rows.values.firstOrNull { it.state == "QUEUED" }?.job
override fun markRunning(jobId: Long) { rows[jobId]?.state = "RUNNING" }
override fun markDone(jobId: Long) { rows[jobId]?.state = "DONE" }
override fun markFailed(jobId: Long, lastError: String) { rows[jobId]?.state = "FAILED"; rows[jobId]?.lastError = lastError }
}
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubRefData : RefDataRepository {
private val gens = mutableMapOf<String, RefDataRepository.GenMeta>()
private val flat = mutableMapOf<Pair<String, String>, String>()
fun clear() { gens.clear(); flat.clear() }
override fun getGen(day: String): RefDataRepository.GenMeta? = gens[day]
override fun putGenIfVersion(day: String, expected: Long, new: RefDataRepository.GenMeta): Boolean {
val cur = gens[day]?.version ?: 0L
if (cur != expected) return false
gens[day] = new
return true
}
override fun upsertAll(rows: List<Triple<String, String, String>>) {
rows.forEach { flat[it.first to it.second] = it.third }
}
}
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubReqTrack : ReqTrackRepository {
private val rows = mutableMapOf<Long, ReqTrackRepository.Req>()
fun clear() { rows.clear() }
private val ids = AtomicLong(0)
override fun findOpenByKind(kind: String): ReqTrackRepository.Req? =
rows.values.firstOrNull { it.reqType == kind && it.state in setOf("REGISTERED", "SENT", "WAITING") }
override fun forceExpireOpenOf(kind: String) {
rows.keys.toList().forEach { id ->
val r = rows[id]!!
if (r.reqType == kind && r.state in setOf("REGISTERED", "SENT", "WAITING"))
rows[id] = r.copy(state = "EXPIRED")
}
}
override fun insert(kind: String, paramsJson: String): Long {
val id = ids.incrementAndGet()
rows[id] = ReqTrackRepository.Req(id, kind, "REGISTERED")
return id
}
override fun linkCoutmsgs(reqId: Long, coutmsgsId: Long) = Unit
override fun markSent(reqId: Long, sentAt: Instant) { rows[reqId]?.let { rows[reqId] = it.copy(state = "SENT", sentAt = sentAt) } }
override fun expireIfWaiting(reqId: Long) { rows[reqId]?.let { rows[reqId] = it.copy(state = "EXPIRED") } }
override fun markDone(reqId: Long) { rows[reqId]?.let { rows[reqId] = it.copy(state = "DONE") } }
}
@Requires(property = "msgx.stubs", value = "true")
@Singleton
class StubFlightState : FlightStateRepository {
private val byDay = mutableMapOf<String, MutableList<Pair<String, String>>>()
fun clear() { byDay.clear() }
override fun replaceDay(day: String, flights: List<Pair<String, String>>) {
byDay[day] = flights.toMutableList()
}
override fun findByDay(day: String): List<Pair<String, String>> = byDay[day] ?: emptyList()
}
@@ -34,6 +34,11 @@ class Pump(
@Volatile
private var running = true
/** 优雅停机:loop 在当前 tick 收尾后退出;线程中断由 Runner 负责。 */
fun stop() {
running = false
}
fun loop() {
while (running) {
try {
+23 -2
View File
@@ -1,6 +1,27 @@
# 开发/影子对拍环境 profileU03/N32):放开管理端点可见性以便“改值—回读”与 /beans 装配核验。
# 生产环境不加载本 profile/env、/beans 保持默认 sensitive)。
# 开发/影子对拍环境 profileU03/N32 + U07):
# - /env、/beans 放开以便“改值—回读”与装配核验(生产 profile 不加载本文件)
# - msgx.stubs=true:无 MySQL/Redis/Kafka 时以内存 stub 仓储跑通「启动 + 收报 + 主泵/投递循环」
# - msgx.pipeline.autostart=true:启动即拉起 Pump/Dispatcher 专用线程
# 启动:MICRONAUT_ENVIRONMENTS=dev ./gradlew run
msgx:
register-eureka: false
stubs: true
pipeline:
autostart: true
# DB 仅在真实实装接入后需要;stub 模式不建连(Flyway 关闭;datasource 保留默认值以免占位符解析失败)
flyway:
datasources:
default:
enabled: false
datasources:
default:
url: ${MSGX_DB_URL:jdbc:mysql://127.0.0.1:3306/msgexchange_dev}
username: ${MSGX_DB_USER:root}
password: ${MSGX_DB_PASSWORD:}
driver-class-name: com.mysql.cj.jdbc.Driver
micronaut:
endpoints:
env:
+1
View File
@@ -15,6 +15,7 @@ msgx:
backoff-ms: [1000, 2000, 4000, 8000, 16000] # 指数退避,单次封顶 60s
backoff-cap-ms: 60000
head-deadline: 10m # 队头滞留上界 = 最坏 HOL 时长(毒丸升级)
autostart: false # U07:启动即拉起 Pump/Dispatcher 循环;需真实仓储或 msgx.stubs=true 才开启(dev 见 application-dev.yml
schd:
flush-period: 3s # KEEP 现役推送节律
flush-limit: 500 # 批上限,防积压尖峰
@@ -0,0 +1,108 @@
package com.gzzn.omms.msgexchange.nextgen
import com.gzzn.omms.msgexchange.nextgen.delivery.Dispatcher
import com.gzzn.omms.msgexchange.nextgen.domain.ErrorClass
import com.gzzn.omms.msgexchange.nextgen.domain.MsgEvent
import com.gzzn.omms.msgexchange.nextgen.domain.ProcStatus
import com.gzzn.omms.msgexchange.nextgen.domain.Targets
import com.gzzn.omms.msgexchange.nextgen.infra.stub.StubDeliveryPort
import com.gzzn.omms.msgexchange.nextgen.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.nextgen.infra.stub.StubProcState
import com.gzzn.omms.msgexchange.nextgen.ingress.InboxController
import com.gzzn.omms.msgexchange.nextgen.processing.Pump
import io.micronaut.context.ApplicationContext
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.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* U07 端到端(stub 装配,等价 /beans 核验):无 MySQL/Redis/Kafka 下,
* ①核心 bean 装配齐全;②收报→落库→主泵领取→解码未实装→FAILED(CODEC_ERROR)+退避;
* ③U11 重放把 FAILED 拉回 PENDING;④Dispatcher flushSchd 聚合发出 schd。
* 后台循环关闭(autostart=false),按需手动 tick,避免测试泄漏线程。
*/
@MicronautTest
class PipelineSmokeTest {
@Inject
lateinit var ctx: ApplicationContext
@Inject
lateinit var controller: InboxController
@Inject
lateinit var pump: Pump
@Inject
lateinit var dispatcher: Dispatcher
// @MicronautTest 复用同一上下文:用例前清空内存 stub,避免全局状态跨用例串扰
@org.junit.jupiter.api.BeforeEach
fun cleanStubs() {
ctx.getBean(StubProcState::class.java).clear()
ctx.getBean(com.gzzn.omms.msgexchange.nextgen.infra.stub.StubInbox::class.java).clear()
ctx.getBean(StubMsgEvents::class.java).clear()
ctx.getBean(StubDeliveryPort::class.java).clear()
}
@Test
fun `core pipeline beans are wired under stubs`() {
assertNotNull(ctx.getBean(com.gzzn.omms.msgexchange.nextgen.config.PipelineProps::class.java))
assertTrue(ctx.getBeansOfType(Pump::class.java).isNotEmpty())
assertTrue(ctx.getBeansOfType(Dispatcher::class.java).isNotEmpty())
assertNotNull(controller)
}
@Test
fun `accept then pump tick transitions message to FAILED CODEC_ERROR with backoff`() {
val receipt = controller.send("<MSG/>")
assertNotNull(receipt.body()) // 受理 ID
val id = receipt.body()!!.toLong()
pump.tick() // 主泵领取 → stub codec 未实装 → FAILED(CODEC_ERROR)
val stub = ctx.getBean(StubProcState::class.java)
val s = stub.snapshotOf(id)
assertNotNull(s)
assertEquals(ProcStatus.FAILED, s!!.state)
assertEquals(ErrorClass.CODEC_ERROR, s.errorClass)
assertEquals(1, s.attempts)
assertNotNull(s.nextAttemptAt)
}
@Test
fun `replay reopens failed CODEC_ERROR row to PENDING`() {
val receipt = controller.send("<MSG/>")
val id = receipt.body()!!.toLong()
pump.tick()
val stub = ctx.getBean(StubProcState::class.java)
assertEquals(ProcStatus.FAILED, stub.snapshotOf(id)!!.state)
val n = ctx.getBean(com.gzzn.omms.msgexchange.nextgen.infra.retry.ReplayService::class.java)
.replay(listOf(ErrorClass.CODEC_ERROR))
assertEquals(1, n)
val reopened = stub.snapshotOf(id)!!
assertEquals(ProcStatus.PENDING, reopened.state)
assertEquals(0, reopened.attempts)
}
@Test
fun `dispatcher flushes schd batch through stub port`() {
val events = ctx.getBean(StubMsgEvents::class.java)
val port = ctx.getBean(StubDeliveryPort::class.java)
events.insertAll(listOf(
MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = "F1", payloadJson = """{"FLID":"F1","v":"old"}"""),
MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = "F1", payloadJson = """{"FLID":"F1","v":"new"}"""),
))
dispatcher.flushSchd()
assertEquals(1, port.sent.count { it.first == "schd" })
val payload = port.sent.first { it.first == "schd" }.second
assertTrue(payload.contains("new") && !payload.contains("old"))
}
}
+9 -4
View File
@@ -1,6 +1,14 @@
# U03 配置绑定回归测试专用环境(@MicronautTest 默认 environment=test
# 目的:让 ApplicationContext 可无外部依赖启动(内存 datasource / 关闭迁移 / 关闭注册),
# 以便以「注入 PipelineProps 断言」离线验证 msgx.* 嵌套键绑定(N02 验收),不依赖 /env 端点。
# 以便以「注入 PipelineProps/DataSource 断言」离线验证绑定(N02/R09/R01a),不依赖 /env 端点。
# U07stubs=true 提供内存仓储使全链路(Controller→Inbox→Pump→Dispatcher)可装配;
# autostart=false:测试内不自动拉起后台循环(避免泄漏线程),按需手动 tick。
msgx:
register-eureka: false
stubs: true
pipeline:
autostart: false
datasources:
default:
url: jdbc:h2:mem:cfgtest;DB_CLOSE_DELAY=-1
@@ -20,9 +28,6 @@ kafka:
bootstrap:
servers: 127.0.0.1:9092
msgx:
register-eureka: false
micronaut:
server:
port: 0